From 8084ac50b39ace8bbbdc8854c439e104bca26dec Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 30 Jul 2026 11:12:56 +0200 Subject: [PATCH] init --- .gitattributes | 1 + .gitignore | 40 + TODO.md | 248 ++ combatant-client-26.2/.gitignore | 45 + combatant-client-26.2/LICENSE | 674 ++++ combatant-client-26.2/README.md | 195 + combatant-client-26.2/build.gradle | 399 ++ combatant-client-26.2/gradle.properties | 20 + .../gradle/wrapper/gradle-wrapper.properties | 7 + combatant-client-26.2/lombok.config | 14 + combatant-client-26.2/settings.gradle | 9 + .../command/impl/ProfilerCommand.java | 301 ++ .../loki/lovisual/mixins/ProfilersMixin.java | 25 + .../profiler/DevFrameStutterProfiler.java | 495 +++ .../engine/profiler/DevGlSyncTracker.java | 340 ++ .../engine/profiler/DevJfrProfiler.java | 253 ++ .../engine/profiler/DevProfilerPhase.java | 67 + .../profiler/DevRenderCostProfiler.java | 457 +++ .../profiler/DevRenderFrameProfiler.java | 135 + .../engine/profiler/DevRenderProfiler2D.java | 499 +++ .../engine/profiler/DevRenderProfiler3D.java | 421 ++ .../engine/profiler/DevSamplingProfiler.java | 447 +++ .../engine/profiler/DevTracyGpuProfiler.java | 150 + .../engine/profiler/DevTracyProfiler.java | 230 ++ .../dev/resources/lovisual-dev.mixins.json | 16 + .../main/java/dev/loki/lovisual/LoVisual.java | 525 +++ .../loki/lovisual/addon/AddonDescriptor.java | 22 + .../dev/loki/lovisual/addon/AddonIssue.java | 21 + .../dev/loki/lovisual/addon/AddonManager.java | 540 +++ .../lovisual/addon/AddonRegistration.java | 79 + .../addon/AddonRenderPipelineManager.java | 193 + .../loki/lovisual/addon/AddonSnapshot.java | 32 + .../loki/lovisual/addon/AddonStateStore.java | 63 + .../dev/loki/lovisual/addon/AddonStatus.java | 17 + .../addon/ClickGuiSectionManager.java | 144 + .../addon/LoVisualAddonContextImpl.java | 206 + .../LoVisualAddonRuntimeContextImpl.java | 29 + .../addon/LoVisualModuleLoadContextImpl.java | 68 + .../addon/ModuleExtensionContextImpl.java | 55 + .../addon/ModuleExtensionManager.java | 171 + .../lovisual/api/v0/addon/LoVisualAddon.java | 45 + .../api/v0/addon/LoVisualAddonContext.java | 55 + .../v0/addon/LoVisualAddonRuntimeContext.java | 16 + .../v0/addon/LoVisualModuleLoadContext.java | 43 + .../LoVisualClickGuiRenderContext.java | 18 + .../v0/clickgui/LoVisualClickGuiSection.java | 48 + .../api/v0/client/LoVisualClientApi.java | 40 + .../v0/module/LoVisualModuleExtension.java | 75 + .../api/v0/module/ModuleExtensionContext.java | 26 + .../render/LoVisualPostProcessCallback.java | 13 + .../v0/render/LoVisualPostProcessContext.java | 25 + .../api/v0/render/LoVisualRenderCallback.java | 13 + .../api/v0/render/LoVisualRenderContext.java | 36 + .../render/LoVisualRenderPipelineBuilder.java | 109 + .../api/v0/render/LoVisualRenderStage.java | 22 + .../api/v0/render/LoVisualUniforms.java | 74 + .../xaero/TriangulatorXaeroMinimapCompat.java | 75 + .../compat/xaero/TriangulatorXaeroState.java | 87 + .../TriangulatorXaeroWorldMapCompat.java | 40 + .../xaero/XaeroCommandWaypointCompat.java | 31 + .../compat/xaero/XaeroWaypointRuntime.java | 229 ++ .../compat/xaero/XaeroWaypointStore.java | 249 ++ .../java/dev/loki/lovisual/events/Event.java | 21 + .../dev/loki/lovisual/events/EventBus.java | 263 ++ .../loki/lovisual/events/EventHandler.java | 20 + .../java/dev/loki/lovisual/events/Events.java | 14 + .../loki/lovisual/events/UsedImplicitly.java | 31 + .../events/impl/AttackEntityEvent.java | 25 + .../events/impl/BlinkPacketEvent.java | 48 + .../impl/CombatProtocolBossbarEvent.java | 26 + .../impl/CrosshairTargetUpdateEvent.java | 30 + .../lovisual/events/impl/EventBreakBlock.java | 22 + .../lovisual/events/impl/EventCollision.java | 27 + .../lovisual/events/impl/EventPostSync.java | 15 + .../events/impl/EventPushOutOfBlocks.java | 23 + .../loki/lovisual/events/impl/EventSync.java | 69 + .../events/impl/EventTargetChanged.java | 32 + .../lovisual/events/impl/FireworkEvent.java | 24 + .../lovisual/events/impl/GameTickEvent.java | 16 + .../impl/I18nPreflightCollectEvent.java | 94 + .../lovisual/events/impl/KeyInputEvent.java | 24 + .../events/impl/KeybindIsPressedEvent.java | 27 + .../lovisual/events/impl/LightmapEvent.java | 34 + .../events/impl/LightmapModifyEvent.java | 81 + .../events/impl/MovementInputEvent.java | 45 + .../lovisual/events/impl/PacketEvent.java | 76 + .../lovisual/events/impl/PlayerJumpEvent.java | 25 + .../lovisual/events/impl/PlayerMoveEvent.java | 33 + .../events/impl/PlayerSafeWalkEvent.java | 19 + .../lovisual/events/impl/PlayerStepEvent.java | 23 + .../events/impl/PlayerStepSuccessEvent.java | 22 + .../events/impl/PlayerVelocityStrafe.java | 31 + .../events/impl/PostPlayerUpdateEvent.java | 19 + .../lovisual/events/impl/PvpChatEvent.java | 33 + .../lovisual/events/impl/PvpOverlayEvent.java | 30 + .../lovisual/events/impl/PvpTabEvent.java | 35 + .../impl/RenderPrewarmCollectEvent.java | 81 + .../events/impl/RotationUpdateEvent.java | 28 + .../events/impl/SprintControlEvent.java | 43 + .../features/account/AccountAuthType.java | 35 + .../features/account/AccountConfig.java | 473 +++ .../features/account/AccountEntry.java | 91 + .../features/account/SkinManager.java | 183 + .../features/command/ClientCommand.java | 63 + .../features/command/CommandContext.java | 20 + .../features/command/CommandInfo.java | 28 + .../features/command/CommandManager.java | 212 ++ .../features/command/CommandMetadata.java | 124 + .../features/command/CommandOutput.java | 195 + .../command/VanillaChatRuntimeCleanup.java | 25 + .../features/command/impl/AddonsCommand.java | 98 + .../features/command/impl/BindCommand.java | 83 + .../features/command/impl/CommandUtils.java | 95 + .../features/command/impl/ConfigCommand.java | 54 + .../command/impl/CoordinatesCommand.java | 35 + .../features/command/impl/HelpCommand.java | 157 + .../features/command/impl/HideCommand.java | 53 + .../features/command/impl/IrisCommand.java | 67 + .../features/command/impl/ModulesCommand.java | 110 + .../features/command/impl/PingCommand.java | 30 + .../command/impl/RelationCommand.java | 160 + .../features/command/impl/RuntimeCommand.java | 78 + .../command/impl/ServerInfoCommand.java | 48 + .../features/command/impl/ToggleCommand.java | 61 + .../features/command/impl/TpsCommand.java | 31 + .../command/impl/UsernameCommand.java | 32 + .../command/impl/XaeroWaypointCommand.java | 215 ++ .../gui/chat/BetterChatHoverCache.java | 419 ++ .../gui/chat/BetterChatMessageGrouping.java | 86 + .../features/gui/chat/BetterChatRenderer.java | 2870 ++++++++++++++ .../features/gui/chat/BetterChatSearch.java | 131 + .../features/gui/chat/BetterChatStore.java | 67 + .../gui/chat/BetterChatStoreManager.java | 424 +++ .../features/gui/chat/ChatHoverUtil.java | 240 ++ .../lovisual/features/gui/chat/ChatLine.java | 16 + .../gui/chat/CommandSuggestorBridge.java | 259 ++ .../gui/clickgui/ClickGuiEditorScreen.java | 64 + .../gui/clickgui/ClickGuiPickerScreen.java | 64 + .../gui/clickgui/ClickGuiRenderer.java | 1900 +++++++++ .../features/gui/clickgui/ClickGuiScreen.java | 75 + .../features/gui/clickgui/ClickGuiSearch.java | 84 + .../editor/ClickGuiTextEditorState.java | 538 +++ .../screen/modules/ModulesMenuCategory.java | 59 + .../screen/modules/ModulesMenuPanel.java | 310 ++ .../screen/modules/ModulesMenuResolver.java | 90 + .../screen/modules/ModulesMenuScreen.java | 914 +++++ .../screen/modules/ModulesMenuStyle.java | 131 + .../layout/screen/settings/MenuScreen.java | 669 ++++ .../screen/settings/MenuSettingsResolver.java | 287 ++ .../screen/settings/SettingsGuiPalette.java | 311 ++ .../implement/category/CategoryComponent.java | 148 + .../implement/main/MainSettingsComponent.java | 454 +++ .../implement/module/ModuleComponent.java | 612 +++ .../module/ModuleSettingsWindow.java | 12 + .../module/SettingsPanelComponent.java | 703 ++++ .../implement/other/BackgroundComponent.java | 30 + .../other/CategoryContainerComponent.java | 74 + .../implement/other/SearchComponent.java | 81 + .../implement/other/StatusRender.java | 36 + .../OnlineRelationPlayerPickerComponent.java | 833 ++++ .../RelationPlayerCardComponent.java | 119 + .../relations/RelationsComponent.java | 844 ++++ .../implement/theme/ThemeComponent.java | 569 +++ .../theme/ThemeEditorPreviewComponent.java | 187 + .../settings/render/LayoutRender2D.java | 118 + .../clickgui/picker/ClickGuiPickerState.java | 963 +++++ .../gui/clickgui/picker/PickerCatalog.java | 17 + .../clickgui/picker/PickerCatalogFactory.java | 196 + .../gui/clickgui/picker/PickerEntryData.java | 16 + .../CombatProtocolHeuristicsEditorState.java | 932 +++++ .../clickgui/sections/ClickGuiSection.java | 41 + .../gui/clickgui/sections/ModulesSection.java | 70 + .../clickgui/sections/SettingsSection.java | 104 + .../sections/settings/SettingsTabRuntime.java | 106 + .../gui/clickgui/settings/BooleanSetting.java | 74 + .../clickgui/settings/ColorPresetStore.java | 140 + .../gui/clickgui/settings/ColorSetting.java | 143 + .../settings/ColorSettingNoAlpha.java | 17 + .../settings/ConfigColorSettingNoAlpha.java | 34 + .../settings/CooldownRulesSetting.java | 592 +++ .../settings/FunctionBindSetting.java | 232 ++ .../gui/clickgui/settings/GroupSetting.java | 100 + .../settings/I18nDuplicateScanner.java | 125 + .../settings/I18nPreflightContributors.java | 133 + .../settings/I18nPreflightManager.java | 130 + .../gui/clickgui/settings/KeyBindSetting.java | 140 + .../gui/clickgui/settings/ModeSetting.java | 126 + .../settings/ModulesSettingsSkin.java | 145 + .../clickgui/settings/PickerDetailOwner.java | 40 + .../settings/ProtocolHeuristicsSetting.java | 61 + .../gui/clickgui/settings/Setting.java | 482 +++ .../gui/clickgui/settings/SettingFactory.java | 75 + .../settings/SettingRenderContext.java | 57 + .../settings/SettingRenderSurface.java | 14 + .../settings/SettingRendererBridge.java | 269 ++ .../gui/clickgui/settings/SliderSetting.java | 99 + .../clickgui/settings/TextEditorOwner.java | 16 + .../clickgui/settings/TextListSetting.java | 153 + .../gui/clickgui/settings/TextSetting.java | 94 + .../settings/UnifiedSettingRenderer.java | 2798 ++++++++++++++ .../settings/UnifiedSettingsSkin.java | 250 ++ .../clickgui/util/ClickGuiHintOverlay.java | 69 + .../gui/clickgui/util/ClickGuiI18n.java | 33 + .../gui/clickgui/util/ClickGuiMath.java | 45 + .../util/ClickGuiRichTextRenderer.java | 207 + .../features/gui/hud/AbstractHudElement.java | 32 + .../features/gui/hud/BaseHudElement.java | 480 +++ .../features/gui/hud/CooldownRender.java | 44 + .../lovisual/features/gui/hud/HudAnchorX.java | 15 + .../lovisual/features/gui/hud/HudAnchorY.java | 15 + .../gui/hud/HudElementAutoLoader.java | 125 + .../features/gui/hud/HudElementInfo.java | 28 + .../features/gui/hud/HudElementRegister.java | 22 + .../features/gui/hud/HudElements.java | 26 + .../features/gui/hud/HudGlobalConfig.java | 132 + .../features/gui/hud/HudRenderSpace.java | 14 + .../features/gui/hud/HudRenderUtil.java | 272 ++ .../features/gui/hud/HudTextEffects.java | 117 + .../features/gui/hud/WidgetAnchorSide.java | 16 + .../hud/draggable/DraggableHudElement.java | 229 ++ .../DraggableHudElementRegistry.java | 1459 +++++++ .../hud/draggable/DraggableLayoutValue.java | 137 + .../gui/hud/draggable/impl/Admins.java | 700 ++++ .../gui/hud/draggable/impl/Armor.java | 320 ++ .../gui/hud/draggable/impl/BetterChat.java | 195 + .../gui/hud/draggable/impl/Cooldowns.java | 702 ++++ .../gui/hud/draggable/impl/Coordinates.java | 333 ++ .../features/gui/hud/draggable/impl/Fps.java | 305 ++ .../gui/hud/draggable/impl/GameTime.java | 307 ++ .../gui/hud/draggable/impl/HudNotifier.java | 1117 ++++++ .../draggable/impl/HudPanelLayoutModes.java | 34 + .../gui/hud/draggable/impl/Inventory.java | 465 +++ .../gui/hud/draggable/impl/Itemizer.java | 699 ++++ .../gui/hud/draggable/impl/Keybinds.java | 603 +++ .../gui/hud/draggable/impl/MediaPlayer.java | 740 ++++ .../gui/hud/draggable/impl/Memory.java | 301 ++ .../gui/hud/draggable/impl/ModuleList.java | 499 +++ .../features/gui/hud/draggable/impl/Ping.java | 263 ++ .../gui/hud/draggable/impl/Potions.java | 617 +++ .../gui/hud/draggable/impl/Radar.java | 387 ++ .../gui/hud/draggable/impl/Scoreboard.java | 922 +++++ .../impl/ScriptedInventoryHudPanel.java | 290 ++ .../draggable/impl/ScriptedListHudPanel.java | 501 +++ .../impl/ScriptedTriangulatorHudPanel.java | 392 ++ .../gui/hud/draggable/impl/SpeedBps.java | 329 ++ .../gui/hud/draggable/impl/SystemTime.java | 278 ++ .../gui/hud/draggable/impl/TargetHud.java | 1687 ++++++++ .../hud/draggable/impl/TimerIndicator.java | 196 + .../features/gui/hud/draggable/impl/Tps.java | 334 ++ .../gui/hud/draggable/impl/Triangulator.java | 1102 ++++++ .../StaticHudElementBootstrap.java | 23 + .../StaticHudElementRegistry.java | 199 + .../hud/nondraggable/impl/BetterButtons.java | 1034 +++++ .../hud/nondraggable/impl/BetterTooltips.java | 949 +++++ .../gui/hud/nondraggable/impl/CustomBar.java | 1496 ++++++++ .../nondraggable/impl/CustomHealthBar.java | 413 ++ .../hud/nondraggable/impl/CustomHotbar.java | 548 +++ .../hud/nondraggable/impl/DynamicIsland.java | 1255 ++++++ .../hud/nondraggable/impl/SwapTooltip.java | 268 ++ .../nondraggable/impl/tab/CustomTabList.java | 829 ++++ .../impl/tab/TabListAnimator.java | 65 + .../nondraggable/impl/tab/TabListModel.java | 531 +++ .../impl/tab/TabRichTextRenderer.java | 337 ++ .../gui/hud/script/CompactHudStatModel.java | 893 +++++ .../gui/hud/script/HudScriptLayouts.java | 125 + .../hud/script/ScriptableHudStatWidget.java | 13 + .../ScriptedCompactHudStatRenderer.java | 87 + .../mainmenu/LoVisualAddonManagerScreen.java | 992 +++++ .../mainmenu/LoVisualAltManagerScreen.java | 1115 ++++++ .../gui/mainmenu/LoVisualMainMenuScreen.java | 561 +++ .../mainmenu/LoVisualProxyManagerScreen.java | 591 +++ .../lovisual/features/module/HudPhase.java | 56 + .../loki/lovisual/features/module/Module.java | 1181 ++++++ .../module/ModuleActivationSource.java | 16 + .../features/module/ModuleAutoLoader.java | 190 + .../features/module/ModuleCategory.java | 16 + .../lovisual/features/module/ModuleInfo.java | 35 + .../features/module/ModuleManager.java | 601 +++ .../features/module/ModuleRegister.java | 22 + .../features/module/ModuleStateListener.java | 12 + .../lovisual/features/module/Modules.java | 38 + .../lovisual/features/module/Notifier.java | 66 + .../features/module/RuntimeControlModule.java | 12 + .../lovisual/features/module/WorldPhase.java | 18 + .../module/modules/combat/AttributeSwap.java | 1922 ++++++++++ .../module/modules/combat/AutoAnchor.java | 1147 ++++++ .../module/modules/combat/AutoAttack.java | 587 +++ .../module/modules/combat/AutoBed.java | 1192 ++++++ .../module/modules/combat/AutoBow.java | 1195 ++++++ .../module/modules/combat/AutoCrystal.java | 1516 ++++++++ .../module/modules/combat/Backtrack.java | 410 ++ .../module/modules/combat/Criticals.java | 766 ++++ .../module/modules/combat/ElytraTarget.java | 1711 +++++++++ .../module/modules/combat/Hitbox.java | 469 +++ .../module/modules/combat/KTLeave.java | 299 ++ .../module/modules/combat/KillAura.java | 1241 ++++++ .../module/modules/combat/MaceKill.java | 138 + .../modules/combat/ProjectilePuncher.java | 716 ++++ .../module/modules/combat/PvpCooldowns.java | 407 ++ .../features/module/modules/combat/Reach.java | 263 ++ .../module/modules/combat/RubberHand.java | 126 + .../module/modules/combat/SpearAssist.java | 43 + .../module/modules/combat/TPSSync.java | 43 + .../autoanchor/AutoAnchorActionUtil.java | 144 + .../autoanchor/AutoAnchorDamageCandidate.java | 16 + .../autoanchor/AutoAnchorDamageRules.java | 88 + .../combat/autoanchor/AutoAnchorData.java | 25 + .../autoanchor/AutoAnchorEntityBlocker.java | 31 + .../autoanchor/AutoAnchorInteractionUtil.java | 306 ++ .../autoanchor/AutoAnchorPlacementMode.java | 14 + .../combat/autoanchor/AutoAnchorPlanner.java | 332 ++ .../autoanchor/AutoAnchorRenderUtil.java | 108 + .../combat/autobed/AutoBedActionUtil.java | 103 + .../autobed/AutoBedDamageCandidate.java | 13 + .../combat/autobed/AutoBedDamageRules.java | 88 + .../modules/combat/autobed/AutoBedData.java | 31 + .../combat/autobed/AutoBedEntityBlocker.java | 31 + .../autobed/AutoBedInteractionUtil.java | 348 ++ .../combat/autobed/AutoBedPlacementMode.java | 14 + .../combat/autobed/AutoBedPlanner.java | 358 ++ .../combat/autobed/AutoBedRenderUtil.java | 108 + .../autocrystal/AutoCrystalAttempt.java | 37 + .../autocrystal/AutoCrystalBaseEvaluator.java | 103 + .../autocrystal/AutoCrystalBasePlanner.java | 137 + .../autocrystal/AutoCrystalCrystalData.java | 18 + .../AutoCrystalDamageCandidate.java | 16 + .../autocrystal/AutoCrystalDamageRules.java | 78 + .../autocrystal/AutoCrystalEntityBlocker.java | 91 + .../combat/autocrystal/AutoCrystalHand.java | 13 + .../AutoCrystalInteractionUtil.java | 222 ++ .../autocrystal/AutoCrystalPlaceData.java | 20 + .../AutoCrystalPlaceScanDebug.java | 107 + .../AutoCrystalPlaceValidationResult.java | 15 + .../autocrystal/AutoCrystalPlacementMode.java | 13 + .../AutoCrystalPlacementPlanner.java | 269 ++ .../autocrystal/AutoCrystalRenderUtil.java | 108 + .../autocrystal/AutoCrystalSelectionUtil.java | 63 + .../autocrystal/AutoCrystalTracker.java | 138 + .../module/modules/misc/BetterMinecraft.java | 34 + .../module/modules/misc/ClickGui.java | 102 + .../module/modules/misc/DefineTarget.java | 331 ++ .../module/modules/misc/FakePlayer.java | 347 ++ .../module/modules/misc/HitSounds.java | 180 + .../module/modules/misc/MessageFilter.java | 61 + .../features/module/modules/misc/NoSound.java | 42 + .../features/module/modules/misc/Panic.java | 82 + .../module/modules/movement/AirJump.java | 116 + .../module/modules/movement/AutoDodge.java | 941 +++++ .../module/modules/movement/ElytraFly.java | 764 ++++ .../module/modules/movement/ElytraHelper.java | 498 +++ .../module/modules/movement/Flight.java | 459 +++ .../module/modules/movement/Freeze.java | 25 + .../modules/movement/InventoryMove.java | 500 +++ .../module/modules/movement/NoFall.java | 717 ++++ .../module/modules/movement/NoPush.java | 81 + .../module/modules/movement/NoStun.java | 570 +++ .../module/modules/movement/Parkour.java | 60 + .../module/modules/movement/Phase.java | 367 ++ .../module/modules/movement/ReverseStep.java | 402 ++ .../module/modules/movement/SafeWalk.java | 129 + .../module/modules/movement/Speed.java | 304 ++ .../module/modules/movement/Sprint.java | 396 ++ .../module/modules/movement/Step.java | 486 +++ .../module/modules/movement/Strafe.java | 253 ++ .../modules/movement/SuperFirework.java | 107 + .../module/modules/movement/TargetStrafe.java | 327 ++ .../module/modules/movement/Timer.java | 429 +++ .../module/modules/movement/Velocity.java | 1079 ++++++ .../module/modules/movement/WindJump.java | 675 ++++ .../module/modules/player/AirPlace.java | 248 ++ .../module/modules/player/AutoArmor.java | 221 ++ .../module/modules/player/AutoEat.java | 384 ++ .../module/modules/player/AutoTool.java | 106 + .../module/modules/player/AutoTotem.java | 339 ++ .../module/modules/player/ClickPearl.java | 159 + .../module/modules/player/FakeLag.java | 156 + .../module/modules/player/NoDelay.java | 358 ++ .../module/modules/player/NoInteract.java | 42 + .../module/modules/player/Scaffold.java | 2416 ++++++++++++ .../module/modules/player/ShitDropper.java | 136 + .../module/modules/player/XCarry.java | 24 + .../module/modules/visuals/AspectRatio.java | 62 + .../module/modules/visuals/BedwarsESP.java | 856 +++++ .../module/modules/visuals/BlockESP.java | 835 ++++ .../modules/visuals/BlockHighlight.java | 596 +++ .../module/modules/visuals/CameraClip.java | 90 + .../module/modules/visuals/Chams.java | 662 ++++ .../module/modules/visuals/Crosshair.java | 844 ++++ .../module/modules/visuals/DamageTint.java | 158 + .../module/modules/visuals/DropESP.java | 392 ++ .../features/module/modules/visuals/ESP.java | 1520 ++++++++ .../module/modules/visuals/FovControl.java | 99 + .../module/modules/visuals/Freecam.java | 243 ++ .../module/modules/visuals/FullBright.java | 43 + .../module/modules/visuals/HitEffect.java | 761 ++++ .../module/modules/visuals/HoleESP.java | 474 +++ .../module/modules/visuals/JumpCircles.java | 304 ++ .../module/modules/visuals/KillEffect.java | 480 +++ .../module/modules/visuals/MotionBlur.java | 301 ++ .../module/modules/visuals/NameTags.java | 1791 +++++++++ .../module/modules/visuals/NoRender.java | 184 + .../module/modules/visuals/PostFX.java | 333 ++ .../module/modules/visuals/Predictions.java | 1363 +++++++ .../modules/visuals/ReimaginedVisual.java | 661 ++++ .../module/modules/visuals/SeeInvisibles.java | 45 + .../module/modules/visuals/SoundESP.java | 206 + .../module/modules/visuals/TargetESP.java | 802 ++++ .../module/modules/visuals/TazikHat.java | 854 +++++ .../module/modules/visuals/TotemFX.java | 161 + .../module/modules/visuals/Tracers.java | 356 ++ .../module/modules/visuals/Trails.java | 676 ++++ .../module/modules/visuals/ViewModel.java | 496 +++ .../modules/visuals/WorldParticles.java | 987 +++++ .../module/modules/visuals/WorldTweaks.java | 360 ++ .../features/module/modules/visuals/Zoom.java | 113 + .../features/relations/CategoryRules.java | 51 + .../features/relations/CategoryService.java | 96 + .../features/relations/CategoryType.java | 17 + .../features/relations/EntityFilters.java | 95 + .../features/relations/PlayerRelations.java | 351 ++ .../features/relations/StaffData.java | 111 + .../relations/StaffHeuristicsConfig.java | 152 + .../relations/StaffHeuristicsMatcher.java | 154 + .../features/relations/StaffTracker.java | 139 + .../features/security/BackdoorProtection.java | 181 + .../FilteredTranslatableTextContent.java | 37 + .../features/theme/EditableClickGuiTheme.java | 212 ++ .../loki/lovisual/features/theme/Theme.java | 70 + .../lovisual/features/theme/ThemeStore.java | 499 +++ .../loki/lovisual/features/theme/Themes.java | 1012 +++++ .../loki/lovisual/mixininterface/IEntity.java | 19 + .../mixininterface/IGlBackendInfo.java | 17 + .../lovisual/mixininterface/IGpuDevice.java | 14 + .../lovisual/mixininterface/IGuiGraphics.java | 28 + .../IItemEntityRenderState.java | 14 + .../lovisual/mixininterface/ILocalPlayer.java | 18 + .../lovisual/mixininterface/IMsaaDevice.java | 21 + .../lovisual/mixininterface/IMsaaTexture.java | 18 + .../mixininterface/IPlayerAttackCooldown.java | 14 + .../mixininterface/IRenderPipeline.java | 26 + .../mixininterface/IRoundedHitbox.java | 18 + .../mixins/AbstractClientPlayerMixin.java | 123 + .../mixins/AbstractContainerScreenMixin.java | 29 + .../mixins/AbstractSelectionListMixin.java | 38 + .../mixins/AbstractSliderButtonMixin.java | 49 + .../lovisual/mixins/AbstractWidgetMixin.java | 87 + .../AtmosphericFogEnvironmentMixin.java | 57 + .../BatchingRenderCommandQueueMixin.java | 73 + .../BlockCollisionSpliteratorMixin.java | 41 + .../dev/loki/lovisual/mixins/BlockMixin.java | 43 + .../mixins/BossHealthOverlayMixin.java | 82 + ...ufferManagerDefaultBufferManagerMixin.java | 41 + .../mixins/ButtonWidgetTextMixin.java | 41 + .../dev/loki/lovisual/mixins/CameraMixin.java | 192 + .../lovisual/mixins/ChatComponentMixin.java | 87 + .../loki/lovisual/mixins/ChatScreenMixin.java | 102 + .../mixins/ClientClockManagerMixin.java | 33 + .../ClientCommonPacketListenerImplMixin.java | 44 + .../lovisual/mixins/ClientLevelMixin.java | 72 + .../mixins/ClientPacketListenerMixin.java | 430 +++ .../lovisual/mixins/CommandEncoderMixin.java | 114 + .../loki/lovisual/mixins/ConnectionMixin.java | 102 + .../lovisual/mixins/ContextualBarMixin.java | 40 + .../lovisual/mixins/CycleButtonMixin.java | 55 + .../loki/lovisual/mixins/DebugHudMixin.java | 145 + .../mixins/DynamicUniformStorageMixin.java | 42 + .../dev/loki/lovisual/mixins/EntityMixin.java | 189 + .../mixins/EquipmentLayerRendererMixin.java | 35 + .../lovisual/mixins/ExperienceBarMixin.java | 51 + .../mixins/FireworkRocketEntityMixin.java | 74 + .../lovisual/mixins/FlowingFluidMixin.java | 55 + .../lovisual/mixins/FogRendererMixin.java | 83 + .../lovisual/mixins/FontManagerMixin.java | 48 + .../lovisual/mixins/GameRendererMixin.java | 811 ++++ .../mixins/GameRendererViewEffectsMixin.java | 44 + .../lovisual/mixins/GlDeviceBackendMixin.java | 25 + .../lovisual/mixins/GlStateManagerMixin.java | 33 + .../loki/lovisual/mixins/GlTextureMixin.java | 58 + .../loki/lovisual/mixins/GpuDeviceMixin.java | 35 + .../loki/lovisual/mixins/GpuSurfaceMixin.java | 54 + .../lovisual/mixins/GuiGraphicsMixin.java | 311 ++ .../dev/loki/lovisual/mixins/GuiMixin.java | 431 +++ .../lovisual/mixins/GuiRendererMixin.java | 263 ++ .../loki/lovisual/mixins/GuiScreenMixin.java | 102 + .../loki/lovisual/mixins/HoneyBlockMixin.java | 37 + .../dev/loki/lovisual/mixins/InputMixin.java | 34 + .../lovisual/mixins/ItemCooldownsMixin.java | 84 + .../mixins/ItemEntityRenderStateMixin.java | 29 + .../mixins/ItemEntityRendererMixin.java | 76 + .../mixins/ItemFeatureRendererMixin.java | 66 + .../mixins/ItemInHandRendererMixin.java | 144 + .../dev/loki/lovisual/mixins/ItemMixin.java | 31 + .../loki/lovisual/mixins/ItemStackMixin.java | 107 + .../mixins/JoinMultiplayerScreenMixin.java | 115 + .../loki/lovisual/mixins/JumpBarMixin.java | 49 + .../loki/lovisual/mixins/KeyMappingMixin.java | 35 + .../lovisual/mixins/KeyboardInputMixin.java | 47 + .../loki/lovisual/mixins/KeyboardMixin.java | 258 ++ .../lovisual/mixins/LavaFogModifierMixin.java | 51 + .../lovisual/mixins/LevelExtractorMixin.java | 58 + .../dev/loki/lovisual/mixins/LevelMixin.java | 43 + .../lovisual/mixins/LevelRendererMixin.java | 282 ++ .../loki/lovisual/mixins/LibraryMixin.java | 37 + .../loki/lovisual/mixins/LightmapMixin.java | 43 + .../LightmapRenderStateExtractorMixin.java | 34 + .../lovisual/mixins/LivingEntityMixin.java | 463 +++ .../mixins/LivingEntityRenderStateMixin.java | 55 + .../mixins/LivingEntityRendererMixin.java | 196 + .../lovisual/mixins/LoVisualMixinPlugin.java | 124 + .../lovisual/mixins/LocalPlayerMixin.java | 904 +++++ .../loki/lovisual/mixins/LocatorBarMixin.java | 52 + .../loki/lovisual/mixins/MinecraftMixin.java | 423 +++ .../dev/loki/lovisual/mixins/MouseMixin.java | 229 ++ .../mixins/MultiPlayerGameModeMixin.java | 185 + .../lovisual/mixins/NarratorManagerMixin.java | 104 + .../lovisual/mixins/OptionsNarratorMixin.java | 45 + .../lovisual/mixins/ParticleManagerMixin.java | 29 + .../lovisual/mixins/PauseScreenMixin.java | 80 + .../lovisual/mixins/PlayerEntityMixin.java | 164 + .../loki/lovisual/mixins/PlayerInfoMixin.java | 110 + .../lovisual/mixins/PlayerInventoryMixin.java | 47 + .../mixins/PlayerTabOverlayMixin.java | 38 + .../mixins/PowderSnowFogModifierMixin.java | 51 + .../lovisual/mixins/ProjectileUtilMixin.java | 84 + .../mixins/RecipeBookScreenMixin.java | 36 + .../loki/lovisual/mixins/RenderPassMixin.java | 29 + .../lovisual/mixins/RenderPipelineMixin.java | 49 + .../mixins/ScreenEffectRendererMixin.java | 85 + .../dev/loki/lovisual/mixins/ScreenMixin.java | 160 + .../mixins/ServerboundUseItemPacketMixin.java | 45 + .../lovisual/mixins/ShaderLoaderMixin.java | 236 ++ .../lovisual/mixins/SkyRendererMixin.java | 47 + .../loki/lovisual/mixins/SlimeBlockMixin.java | 33 + .../SpriteIconButtonCenteredIconMixin.java | 45 + .../SpriteIconButtonTextAndIconMixin.java | 45 + .../lovisual/mixins/TitleScreenMixin.java | 37 + .../lovisual/mixins/ToastManagerMixin.java | 29 + .../mixins/TooltipRenderUtilMixin.java | 47 + .../mixins/VulkanCommandEncoderMixin.java | 95 + .../lovisual/mixins/VulkanDeviceMixin.java | 193 + .../mixins/VulkanGpuTextureMixin.java | 114 + .../mixins/VulkanGpuTextureViewMixin.java | 44 + .../mixins/VulkanRenderPassMixin.java | 52 + .../mixins/VulkanRenderPipelineMixin.java | 180 + .../mixins/WaterFogEnvironmentMixin.java | 51 + .../accessors/BossHealthOverlayAccessor.java | 24 + .../accessors/ChatInputSuggestorAccessor.java | 26 + .../mixins/accessors/ChatScreenAccessor.java | 21 + .../ChatScreenSuggestorAccessor.java | 21 + .../accessors/ClickableWidgetAccessor.java | 21 + .../mixins/accessors/ClientLevelAccessor.java | 29 + .../ClientboundExplodePacketAccessor.java | 24 + ...entboundSetEntityMotionPacketAccessor.java | 28 + .../CyclingButtonWidgetAccessor.java | 28 + .../EffectParticleEffectAccessor.java | 20 + .../mixins/accessors/EntityAccessor.java | 30 + .../mixins/accessors/EntityInvoker.java | 22 + .../EntityRenderManagerAccessor.java | 33 + .../accessors/EntityRendererAccessor.java | 23 + .../accessors/FireworksComponentAccessor.java | 20 + .../accessors/GameRendererAccessor.java | 55 + .../mixins/accessors/GlTextureInvoker.java | 32 + .../mixins/accessors/GpuDeviceAccessor.java | 19 + .../InGameOverlayRendererAccessor.java | 36 + .../mixins/accessors/InputAccessor.java | 26 + .../accessors/ItemCooldownEntryAccessor.java | 17 + .../ItemCooldownManagerAccessor.java | 26 + .../ItemRenderStateLayerAccessor.java | 45 + .../accessors/LevelExtractorAccessor.java | 21 + .../accessors/LevelRendererAccessor.java | 50 + .../accessors/LivingEntityAccessor.java | 26 + .../LivingEntityRenderStateAccessor.java | 25 + .../mixins/accessors/LocalPlayerAccessor.java | 50 + .../mixins/accessors/MinecraftAccessor.java | 86 + .../MultiPlayerGameModeAccessor.java | 42 + .../PersistentProjectileEntityAccessor.java | 20 + .../accessors/PlayerInventoryAccessor.java | 23 + .../accessors/PlayerTabOverlayAccessor.java | 22 + .../accessors/RenderLayerInvokerAccessor.java | 24 + .../ServerboundInteractPacketAccessor.java | 18 + .../ServerboundMovePlayerPacketAccessor.java | 38 + .../ShaderLoaderDefinitionsAccessor.java | 25 + .../accessors/ShaderSourceKeyAccessor.java | 22 + .../accessors/SliderWidgetAccessor.java | 23 + .../mixins/accessors/SlotAccessor.java | 18 + .../accessors/SuggestionWindowAccessor.java | 38 + .../accessors/TextFieldWidgetAccessor.java | 21 + .../TextIconButtonWidgetAccessor.java | 27 + .../mixins/accessors/WorldAccessor.java | 33 + .../mixins/iris/IrisCommonUniformsMixin.java | 32 + .../iris/IrisGameRendererInteropMixin.java | 34 + .../mixins/iris/IrisHandRendererAccessor.java | 31 + .../mixins/iris/IrisHandRendererMixin.java | 52 + .../iris/IrisIncludeProcessorMixin.java | 73 + .../IrisRenderingPipelineFinalizeMixin.java | 54 + .../mixins/iris/IrisShaderPackLoadMixin.java | 30 + .../iris/IrisVanillaHandInteropMixin.java | 61 + .../mixins/moreculling/CullingUtilsMixin.java | 45 + .../security/AbstractSignEditScreenMixin.java | 50 + .../mixins/security/AnvilScreenMixin.java | 41 + .../ClientLoginNetworkHandlerMixin.java | 42 + .../mixins/security/GameOptionsMixin.java | 47 + .../mixins/security/NetworkUtilsMixin.java | 95 + .../TranslatableTextContentMixin.java | 46 + .../security/TranslationStorageMixin.java | 60 + .../sodium/SodiumBlockRendererMixin.java | 52 + .../sodium/SodiumBlockVisibilityMixin.java | 64 + .../SodiumChunkBuilderMeshingTaskMixin.java | 35 + .../sodium/SodiumChunkMeshFormatsMixin.java | 29 + .../mixins/sodium/SodiumChunkVertexMixin.java | 42 + .../SodiumShaderChunkRendererMixin.java | 49 + .../SodiumVertexConsumerTrackerMixin.java | 45 + .../SodiumWorldRendererFreecamMixin.java | 31 + .../AbstractWaypointRenderProviderMixin.java | 53 + .../worldmap/WaypointRenderProviderMixin.java | 80 + .../worldmap/WorldMapWaypointAccessor.java | 30 + .../render/CombatantEntityRenderTypes.java | 96 + .../render/ShaderEspRenderContext.java | 44 + .../render/ViewObstructionFadeContext.java | 54 + .../render/ViewObstructionFadeState.java | 22 + .../render/compat/EntityCullingCompat.java | 31 + .../render/effects/NetherPortalRiftPass.java | 115 + .../render/effects/SleepOverlayPass.java | 85 + .../CombatantRenderEngineBootstrap.java | 29 + .../lovisual/render/engine/Projection2D.java | 63 + .../lovisual/render/engine/RenderState.java | 71 + .../loki/lovisual/render/engine/Texture.java | 90 + .../render/engine/TextureStorage.java | 65 + .../engine/animation/AnimatedClockText.java | 57 + .../engine/animation/AnimatedGlyphSlot.java | 68 + .../animation/AnimatedRenderColors.java | 340 ++ .../engine/animation/AnimatedTextLine.java | 229 ++ .../engine/animation/AnimatedTextStyle.java | 74 + .../animation/AnimatedTextTransition.java | 15 + .../engine/animation/AnimationUtility.java | 114 + .../render/engine/color/ColorBlendMode.java | 38 + .../render/engine/color/ColorEffects.java | 139 + .../render/engine/color/ColorUtils.java | 86 + .../render/engine/color/RenderColor.java | 47 + .../engine/command/RenderCommandBuffer.java | 18 + .../engine/command/UiBatchBackendCommand.java | 22 + .../render/engine/command/UiCommand.java | 18 + .../engine/command/UiCommandBuffer.java | 68 + .../render/engine/command/UiCommandKind.java | 20 + .../render/engine/command/UiCommandStats.java | 57 + .../engine/command/UiEffectCommand.java | 17 + .../engine/command/UiEffectRegionCommand.java | 17 + .../render/engine/command/UiItemCommand.java | 17 + .../render/engine/command/UiPathCommand.java | 19 + .../engine/command/UiPrimitiveCommand.java | 15 + .../render/engine/command/UiShapeCommand.java | 19 + .../engine/command/UiStatsSnapshot.java | 20 + .../render/engine/command/UiTextCommand.java | 15 + .../engine/command/UiTextureCommand.java | 18 + .../engine/command/UiTextureDrawCommand.java | 28 + .../engine/command/WorldCommandBuffer.java | 41 + .../ImmediatelyFastBridge.java | 54 + .../ImmediatelyFastRuntime.java | 63 + .../ImmediatelyFastRuntimeSnapshot.java | 49 + .../render/engine/core/CameraContext.java | 58 + .../engine/core/CombatantRenderSystem.java | 382 ++ .../engine/core/CombatantWorldMatrices.java | 73 + .../render/engine/core/FrameLifecycle.java | 18 + .../engine/core/FramebufferContext.java | 19 + .../engine/core/RenderFrameContext.java | 74 + .../render/engine/core/RenderPhase.java | 33 + .../render/engine/core/RenderPhaseScope.java | 44 + .../render/engine/core/ViewportContext.java | 298 ++ .../engine/core/policy/DepthProvider.java | 18 + .../engine/core/policy/FogProvider.java | 17 + .../engine/core/policy/LightPolicy.java | 16 + .../core/policy/VanillaWorldFogProvider.java | 32 + .../core/policy/VisibilityProvider.java | 39 + .../engine/core/policy/VisibilityQuery.java | 30 + .../debug/RenderThread2DDebugRenderer.java | 337 ++ .../engine/depth/PreTranslucentDepth.java | 212 ++ .../render/engine/depth/WorldSceneDepth.java | 224 ++ .../framegraph/CombatantFrameGraph.java | 47 + .../engine/framegraph/PassScheduler.java | 33 + .../engine/framegraph/RenderPassNode.java | 41 + .../render/engine/guard/LegacyRenderPath.java | 31 + .../engine/guard/RenderArchitectureGuard.java | 61 + .../render/engine/guard/RenderBoundary.java | 36 + .../engine/guard/RenderBoundaryAudit.java | 40 + .../engine/guard/RenderBoundaryExempt.java | 27 + .../render/engine/light/LightmapState.java | 36 + .../render/engine/math/ColorMath.java | 59 + .../lovisual/render/engine/math/HudScale.java | 64 + .../render/engine/math/RenderMath.java | 40 + .../render/engine/msaa/MsaaFramebuffer.java | 83 + .../render/engine/msaa/MsaaResolveUtil.java | 22 + .../engine/msaa/MsaaTextureRegistry.java | 34 + .../render/engine/msaa/MsaaWorldTarget.java | 273 ++ .../pipeline/CombatantRenderPipelines.java | 1329 +++++++ .../engine/pipeline/DepthTestFunction.java | 26 + .../ExtendedRenderPipelineBuilder.java | 170 + .../postprocess/DepthOfFieldDepthSource.java | 28 + .../postprocess/DepthOfFieldQuality.java | 33 + .../postprocess/MenuBackgroundRenderer.java | 129 + .../engine/postprocess/MotionBlurMode.java | 26 + .../postprocess/MotionBlurResources.java | 59 + .../postprocess/PostProcessContext.java | 25 + .../postprocess/PostProcessManager.java | 123 + .../engine/postprocess/PostProcessPass.java | 30 + .../graph/HistoryBufferManager.java | 37 + .../graph/LegacyPostProcessGraphPass.java | 74 + .../postprocess/graph/PostProcessGraph.java | 112 + .../graph/PostProcessGraphPass.java | 39 + .../graph/PostProcessGraphResources.java | 162 + .../graph/PostProcessResolution.java | 15 + .../graph/PostProcessResource.java | 19 + .../prewarm/RenderPrewarmContributors.java | 93 + .../engine/prewarm/RenderPrewarmManager.java | 59 + .../engine/profiler/DevProfilerBridge.java | 84 + .../engine/profiler/FrameStutterProfiler.java | 43 + .../render/engine/profiler/GlSyncTracker.java | 45 + .../render/engine/profiler/JfrProfiler.java | 176 + .../render/engine/profiler/ProfilerLog.java | 22 + .../render/engine/profiler/ProfilerPhase.java | 52 + .../engine/profiler/ProfilerSettings.java | 108 + .../engine/profiler/RenderCostProfiler.java | 126 + .../engine/profiler/RenderFrameProfiler.java | 48 + .../engine/profiler/RenderProfiler2D.java | 65 + .../engine/profiler/RenderProfiler3D.java | 65 + .../engine/profiler/SamplingProfiler.java | 42 + .../engine/profiler/TracyGpuProfiler.java | 42 + .../render/engine/profiler/TracyProfiler.java | 77 + .../engine/renderer/FullScreenRenderer.java | 137 + .../render/engine/renderer/MeshRenderer.java | 233 ++ .../render/engine/renderer/RenderWarp.java | 439 +++ .../engine/renderer/RenderWarpStack.java | 67 + .../render/engine/renderer/Renderer2D.java | 3379 +++++++++++++++++ .../render/engine/renderer/Renderer3D.java | 433 +++ .../render/engine/renderer/ui/BlurSource.java | 21 + .../engine/renderer/ui/Deferred2DSubmit.java | 24 + .../renderer/ui/DeferredActionSubmit.java | 20 + .../renderer/ui/DeferredOrderedSubmit.java | 26 + .../renderer/ui/DeferredTexturedSubmit.java | 45 + .../render/engine/renderer/ui/DrawBatch.java | 81 + .../renderer/ui/FrameBlurCacheEntry.java | 80 + .../renderer/ui/HudDeferredGuiElement.java | 60 + .../render/engine/renderer/ui/ItemBatch.java | 45 + .../engine/renderer/ui/ItemBatchRenderer.java | 673 ++++ .../engine/renderer/ui/ItemDrawCommand.java | 70 + .../engine/renderer/ui/ItemResolveKey.java | 40 + .../engine/renderer/ui/OrderedUiBatcher.java | 937 +++++ .../engine/renderer/ui/SharedBlurProfile.java | 13 + .../render/engine/renderer/ui/TextBatch.java | 63 + .../engine/renderer/ui/UiBatchPlan.java | 31 + .../engine/renderer/ui/UiBatchStats.java | 137 + .../engine/renderer/ui/UiBatchType.java | 49 + .../render/engine/renderer/ui/UiBatcher.java | 42 + .../engine/renderer/ui/UiBlurResources.java | 296 ++ .../renderer/ui/UiDeferredScheduler.java | 243 ++ .../renderer/ui/UiDirectTexturedRenderer.java | 88 + .../engine/renderer/ui/UiEffectGraph.java | 46 + .../engine/renderer/ui/UiItemSubmission.java | 77 + .../engine/renderer/ui/UiPassCompiler.java | 18 + .../engine/renderer/ui/UiPassExecutor.java | 27 + .../renderer/ui/UiRenderDispatcher.java | 135 + .../renderer/ui/UiRendererSubsystem.java | 69 + .../renderer/ui/draw/UiBoxPathBuilder.java | 158 + .../engine/renderer/ui/draw/UiBoxShape.java | 210 + .../renderer/ui/draw/UiChamferRadii.java | 35 + .../engine/renderer/ui/draw/UiCornerKind.java | 30 + .../engine/renderer/ui/draw/UiCornerMode.java | 16 + .../renderer/ui/draw/UiCornerRadii.java | 30 + .../engine/renderer/ui/draw/UiCornerSpec.java | 68 + .../engine/renderer/ui/draw/UiEdgeKind.java | 29 + .../engine/renderer/ui/draw/UiEdgeSpec.java | 45 + .../engine/renderer/ui/draw/UiEffectKind.java | 15 + .../engine/renderer/ui/draw/UiEffectSpec.java | 33 + .../renderer/ui/draw/UiMeshGeometry.java | 954 +++++ .../engine/renderer/ui/draw/UiPaint.java | 38 + .../engine/renderer/ui/draw/UiPaintKind.java | 15 + .../engine/renderer/ui/draw/UiPaints.java | 31 + .../engine/renderer/ui/draw/UiPathCap.java | 14 + .../engine/renderer/ui/draw/UiPathJoin.java | 14 + .../engine/renderer/ui/draw/UiRect.java | 18 + .../engine/renderer/ui/draw/UiShape.java | 177 + .../engine/renderer/ui/draw/UiShapeKind.java | 19 + .../renderer/ui/draw/UiShapeShaderParams.java | 34 + .../engine/renderer/ui/draw/UiShapes.java | 64 + .../engine/renderer/ui/draw/UiStroke.java | 24 + .../renderer/ui/runtime/action/UiAction.java | 13 + .../ui/runtime/action/UiActionContext.java | 13 + .../ui/runtime/action/UiActionRef.java | 36 + .../ui/runtime/action/UiActionRegistry.java | 44 + .../ui/runtime/animation/UiAnimationSpec.java | 24 + .../runtime/animation/UiAnimationState.java | 43 + .../ui/runtime/animation/UiEasing.java | 48 + .../ui/runtime/asset/UiAssetKind.java | 17 + .../ui/runtime/asset/UiAssetProvider.java | 15 + .../renderer/ui/runtime/asset/UiAssetRef.java | 18 + .../ui/runtime/asset/UiAssetRegistry.java | 40 + .../ui/runtime/asset/UiAssetResolver.java | 57 + .../renderer/ui/runtime/core/UiBounds.java | 21 + .../ui/runtime/core/UiComponentFactory.java | 13 + .../ui/runtime/core/UiComponentRegistry.java | 42 + .../renderer/ui/runtime/core/UiDocument.java | 29 + .../renderer/ui/runtime/core/UiLifecycle.java | 19 + .../renderer/ui/runtime/core/UiNode.java | 238 ++ .../renderer/ui/runtime/core/UiNodeSpec.java | 177 + .../renderer/ui/runtime/core/UiNodeType.java | 100 + .../renderer/ui/runtime/core/UiProps.java | 129 + .../renderer/ui/runtime/core/UiRuntime.java | 294 ++ .../ui/runtime/core/UiRuntimeException.java | 18 + .../renderer/ui/runtime/core/UiState.java | 106 + .../ui/runtime/debug/UiPerfCounters.java | 105 + .../runtime/debug/UiRuntimeDiagnostics.java | 22 + .../ui/runtime/debug/UiTreeDumper.java | 61 + .../ui/runtime/error/UiErrorPolicy.java | 14 + .../ui/runtime/input/UiCursorManager.java | 36 + .../ui/runtime/input/UiFocusManager.java | 25 + .../ui/runtime/input/UiHitResult.java | 18 + .../ui/runtime/input/UiHitTester.java | 42 + .../ui/runtime/input/UiInputDispatcher.java | 101 + .../ui/runtime/input/UiInputState.java | 49 + .../ui/runtime/input/UiKeyboardEvent.java | 11 + .../ui/runtime/input/UiPointerEvent.java | 11 + .../ui/runtime/input/UiScrollEvent.java | 11 + .../ui/runtime/layout/UiLayoutEngine.java | 307 ++ .../ui/runtime/reconcile/UiDiffStats.java | 54 + .../ui/runtime/reconcile/UiKeyPath.java | 15 + .../ui/runtime/reconcile/UiNodeMatcher.java | 17 + .../runtime/reconcile/UiReconcileContext.java | 28 + .../ui/runtime/reconcile/UiReconciler.java | 110 + .../ui/runtime/render/UiBlendSpec.java | 80 + .../ui/runtime/render/UiClipStack.java | 39 + .../runtime/render/UiImageRendererBridge.java | 103 + .../runtime/render/UiItemRendererBridge.java | 64 + .../runtime/render/UiPrimitiveRenderer.java | 1042 +++++ .../ui/runtime/render/UiProjectionMode.java | 15 + .../ui/runtime/render/UiRenderContext.java | 38 + .../ui/runtime/render/UiRenderer.java | 247 ++ .../runtime/render/UiTextEffectRenderer.java | 102 + .../ui/runtime/render/UiTextRenderer.java | 196 + .../runtime/script/CachedUiScriptRuntime.java | 425 +++ .../script/JavetNativeResourceLoader.java | 113 + .../runtime/script/JavetUiScriptEngine.java | 188 + .../ui/runtime/script/UiBoundsPatchSet.java | 41 + .../ui/runtime/script/UiScriptColor.java | 41 + .../ui/runtime/script/UiScriptComponent.java | 17 + .../ui/runtime/script/UiScriptEngine.java | 15 + .../script/UiScriptEngineProvider.java | 16 + .../ui/runtime/script/UiScriptModule.java | 27 + .../runtime/script/UiScriptModuleHandle.java | 156 + .../ui/runtime/script/UiScriptModuleId.java | 63 + .../runtime/script/UiScriptModuleLoader.java | 154 + .../script/UiScriptModuleRegistry.java | 63 + .../script/UiScriptModuleTransform.java | 25 + .../script/UiScriptObjectConverter.java | 107 + .../ui/runtime/script/UiScriptPatchSet.java | 93 + .../ui/runtime/script/UiScriptProps.java | 85 + .../runtime/script/UiScriptRenderContext.java | 23 + .../runtime/script/UiScriptRenderResult.java | 24 + .../runtime/script/UiScriptRuntimeError.java | 25 + .../runtime/script/UiScriptRuntimeSource.java | 184 + .../ui/runtime/script/UiScriptSourceKind.java | 31 + .../runtime/script/UiTypeScriptStripper.java | 22 + .../renderer/ui/runtime/style/UiAlign.java | 27 + .../renderer/ui/runtime/style/UiColor.java | 45 + .../renderer/ui/runtime/style/UiJustify.java | 27 + .../renderer/ui/runtime/style/UiOverflow.java | 33 + .../ui/runtime/style/UiResolvedStyle.java | 11 + .../renderer/ui/runtime/style/UiStyle.java | 640 ++++ .../ui/runtime/style/UiStyleCache.java | 45 + .../ui/runtime/style/UiStyleParser.java | 308 ++ .../ui/runtime/style/UiStyleResolver.java | 26 + .../ui/runtime/style/UiStyleToken.java | 24 + .../renderer/ui/runtime/style/UiTheme.java | 52 + .../ui/runtime/style/UiThemeRegistry.java | 21 + .../render/engine/rhi/CombatantRhi.java | 50 + .../engine/rhi/FullscreenDrawCommand.java | 96 + .../render/engine/rhi/GpuMeshHandle.java | 202 + .../render/engine/rhi/MeshOwnership.java | 27 + .../render/engine/rhi/RhiDrawCommand.java | 147 + .../render/engine/rhi/RhiSamplerBinding.java | 14 + .../lovisual/render/engine/rhi/RhiStats.java | 224 ++ .../render/engine/rhi/RhiStatsSnapshot.java | 40 + .../render/engine/rhi/RhiUniformBinding.java | 13 + .../engine/rhi/backend/SodiumGlBackend.java | 260 ++ .../rhi/backend/gl/GlBackendAccess.java | 28 + .../gl/clip/GlStencilFramebufferSupport.java | 663 ++++ .../gl/clip/GlStencilShapeClipBackend.java | 442 +++ .../state/SodiumGlPipelineStateBackend.java | 71 + .../vulkan/CombatantVulkanBackend.java | 257 ++ .../vulkan/clip/VulkanShapeClipBackend.java | 221 ++ .../vulkan/clip/VulkanShapeClipBridge.java | 38 + .../clip/VulkanStencilAttachmentManager.java | 119 + .../vulkan/msaa/VulkanMsaaControl.java | 198 + .../vulkan/msaa/VulkanMsaaResolveBridge.java | 307 ++ .../state/VulkanPipelineStateBackend.java | 46 + .../vulkan/util/VulkanRenderStateBridge.java | 460 +++ .../vulkan/util/VulkanRuntimeGuards.java | 27 + .../rhi/blit/Blaze3dTextureBlitter.java | 47 + .../engine/rhi/blit/TextureBlitter.java | 17 + .../engine/rhi/clip/ShapeClipBackend.java | 104 + .../rhi/clip/ShapeClipRenderPassContract.java | 42 + .../fullscreen/Blaze3dFullscreenBackend.java | 73 + .../rhi/fullscreen/FullscreenBackend.java | 24 + .../render/engine/rhi/msaa/MsaaControl.java | 76 + .../engine/rhi/msaa/SodiumGlMsaaControl.java | 338 ++ .../engine/rhi/pipeline/BlendPolicy.java | 16 + .../engine/rhi/pipeline/CullPolicy.java | 15 + .../engine/rhi/pipeline/DepthPolicy.java | 19 + .../render/engine/rhi/pipeline/FogPolicy.java | 19 + .../engine/rhi/pipeline/PipelineDomain.java | 20 + .../engine/rhi/pipeline/PipelineKey.java | 52 + .../engine/rhi/pipeline/PipelineMetadata.java | 90 + .../rhi/pipeline/RenderPipelineRegistry.java | 119 + .../rhi/pipeline/RenderPipelineSpec.java | 133 + .../rhi/pipeline/UniformLayoutSpec.java | 64 + .../engine/rhi/pipeline/VertexLayoutSpec.java | 25 + .../FrameResourceRetirementQueue.java | 53 + .../rhi/resource/FramebufferDescriptor.java | 44 + .../engine/rhi/resource/FramebufferPool.java | 170 + .../rhi/resource/GlyphAtlasManager.java | 60 + .../engine/rhi/resource/GlyphAtlasPage.java | 13 + .../rhi/resource/RenderResourceManager.java | 114 + .../resource/RenderResourceStatsSnapshot.java | 26 + .../rhi/resource/ResourceLeakTracker.java | 35 + .../engine/rhi/resource/SamplerCache.java | 27 + .../engine/rhi/resource/TexturePool.java | 67 + .../rhi/scissor/GlobalScissorState.java | 81 + .../rhi/state/PipelineStateBackend.java | 23 + .../uniform/CombatantUniformAllocator.java | 327 ++ .../engine/rhi/uniform/FrameResource.java | 16 + .../uniform/LegacyUniformFrameResource.java | 45 + .../rhi/uniform/UniformAllocatorStats.java | 117 + .../UniformAllocatorStatsSnapshot.java | 21 + .../rhi/upload/Blaze3dDynamicMeshBackend.java | 249 ++ .../engine/rhi/upload/Blaze3dFrameFence.java | 56 + .../rhi/upload/Blaze3dMeshAllocation.java | 52 + .../engine/rhi/upload/Blaze3dMeshArena.java | 223 ++ .../engine/rhi/upload/DynamicMeshBackend.java | 24 + .../engine/shader/CombatantShaderSources.java | 182 + .../render/engine/svg/SvgColorMode.java | 14 + .../render/engine/svg/SvgInlineText.java | 137 + .../render/engine/svg/SvgMeshBackend.java | 1014 +++++ .../render/engine/svg/SvgMsdfRegistry.java | 299 ++ .../render/engine/svg/SvgPathParser.java | 434 +++ .../render/engine/svg/SvgRegistry.java | 108 + .../render/engine/svg/SvgRenderOptions.java | 109 + .../lovisual/render/engine/svg/SvgStyle.java | 61 + .../render/engine/svg/SvgStyleUtil.java | 350 ++ .../render/engine/text/BuiltinFontFace.java | 51 + .../engine/text/CustomTextRenderer.java | 455 +++ .../render/engine/text/EmptyGlyphFont.java | 52 + .../lovisual/render/engine/text/Font.java | 388 ++ .../render/engine/text/FontDebugStats.java | 81 + .../lovisual/render/engine/text/FontFace.java | 35 + .../render/engine/text/FontFamily.java | 48 + .../lovisual/render/engine/text/FontInfo.java | 53 + .../render/engine/text/FontUtils.java | 240 ++ .../lovisual/render/engine/text/Fonts.java | 238 ++ .../render/engine/text/GlyphFont.java | 73 + .../lovisual/render/engine/text/MsdfFont.java | 606 +++ .../render/engine/text/SystemFontFace.java | 42 + .../engine/text/TextBackendPreference.java | 18 + .../render/engine/text/TextClipSpec.java | 23 + .../render/engine/text/TextCommandBuffer.java | 59 + .../render/engine/text/TextCommandStats.java | 102 + .../engine/text/TextCommandStatsSnapshot.java | 34 + .../render/engine/text/TextEffectSpec.java | 49 + .../render/engine/text/TextGlyphFallback.java | 95 + .../render/engine/text/TextRenderSystem.java | 143 + .../render/engine/text/TextRenderer.java | 233 ++ .../engine/text/VanillaTextRenderer.java | 264 ++ .../render/engine/text/WorldTextRenderer.java | 210 + .../text/backend/BitmapAtlasTextBackend.java | 83 + .../text/backend/GlyphAtlasManager.java | 54 + .../engine/text/backend/GlyphAtlasPage.java | 13 + .../engine/text/backend/MsdfTextBackend.java | 80 + .../engine/text/backend/TextBackend.java | 35 + .../text/backend/TextBackendRouter.java | 134 + .../engine/text/backend/TextDrawCommand.java | 188 + .../text/backend/TextPlacementMode.java | 44 + .../text/backend/TextPlacementResolver.java | 31 + .../text/backend/TextPlacementTransform.java | 47 + .../engine/text/backend/TextRenderDomain.java | 14 + .../backend/VanillaSodiumTextBackend.java | 81 + .../render/engine/uniform/MeshBuilder.java | 736 ++++ .../render/engine/uniform/MeshData.java | 49 + .../uniform/impl/DepthOfFieldUniforms.java | 131 + .../uniform/impl/HandGlassUniforms.java | 93 + .../uniform/impl/HandMetallicUniforms.java | 104 + .../uniform/impl/HandSmokeUniforms.java | 96 + .../engine/uniform/impl/HeatUniforms.java | 80 + .../uniform/impl/MenuBackgroundUniforms.java | 70 + .../engine/uniform/impl/MeshUniforms.java | 76 + .../uniform/impl/MotionBlurUniforms.java | 112 + .../engine/uniform/impl/MsdfTextUniforms.java | 67 + .../engine/uniform/impl/PostFXUniforms.java | 62 + .../uniform/impl/PostProcessUniforms.java | 63 + .../uniform/impl/ShaderEspBlurUniforms.java | 67 + .../impl/ShaderEspGradientUniforms.java | 95 + .../uniform/impl/ShaderEspSmokeUniforms.java | 94 + .../engine/uniform/impl/SkySunUniforms.java | 63 + .../uniform/impl/SkyboxShaderUniforms.java | 245 ++ .../uniform/impl/TextureTintUniforms.java | 64 + .../engine/uniform/impl/UIBatchUniforms.java | 71 + .../engine/uniform/impl/UIBlurUniforms.java | 101 + .../uniform/storage/FixedUniformStorage.java | 32 + .../vertex/CombatantVertexFormatElements.java | 26 + .../engine/vertex/CombatantVertexFormats.java | 134 + .../engine/visuals/CombatantVisualFrame.java | 51 + .../engine/visuals/CombatantVisualPass.java | 38 + .../engine/visuals/CombatantVisualPhase.java | 19 + .../engine/visuals/CombatantVisuals.java | 180 + .../render/engine/world/WorldBatcher.java | 115 + .../engine/world/WorldCommandBuffer.java | 44 + .../render/engine/world/WorldDrawCommand.java | 49 + .../engine/world/WorldPassCompiler.java | 198 + .../render/engine/world/WorldRenderStats.java | 72 + .../world/WorldRenderStatsSnapshot.java | 20 + .../world/WorldWideLineMeshCompiler.java | 166 + .../lovisual/render/helpers/ClipFunction.java | 503 +++ .../render/helpers/EffectIconRenderer.java | 34 + .../render/helpers/GuiSpriteBatch.java | 63 + .../lovisual/render/helpers/HeadAnchor.java | 210 + .../render/helpers/MatteHudStyle.java | 133 + .../lovisual/render/helpers/Particle3D.java | 106 + .../render/helpers/ParticleTextureMode.java | 26 + .../render/helpers/PlayerHeadRenderer.java | 189 + .../render/helpers/ScissorFunction.java | 218 ++ .../render/helpers/ScreenProjection.java | 87 + .../render/helpers/ScreenSpaceOverlay2D.java | 205 + .../render/helpers/SodiumMaterialFlags.java | 27 + .../helpers/SodiumSurfaceFlagContext.java | 118 + .../lovisual/render/helpers/SystemCursor.java | 111 + .../lovisual/render/helpers/TickDelta.java | 72 + .../lovisual/render/helpers/TrailPoint.java | 56 + .../render/iris/CombatantIrisUniforms.java | 153 + .../render/iris/IrisCombatantFrameHooks.java | 71 + .../render/iris/IrisCompatibilityFeature.java | 17 + .../render/iris/IrisCompatibilityGuards.java | 42 + .../render/iris/IrisCompatibilityProfile.java | 64 + .../iris/IrisCompatibilityProfiles.java | 50 + .../iris/IrisFinalizedSceneRenderer.java | 14 + .../render/iris/IrisHandMaskState.java | 31 + .../lovisual/render/iris/IrisRuntime.java | 78 + .../render/iris/IrisRuntimeBridge.java | 88 + .../render/iris/IrisRuntimeSnapshot.java | 43 + .../lovisual/render/iris/IrisSceneDepth.java | 94 + .../render/iris/IrisSecondHandScene.java | 58 + .../iris/patch/ShaderPatchCompiler.java | 843 ++++ .../iris/patch/ShaderPatchCompilerCli.java | 122 + .../render/iris/patch/ShaderPatchEngine.java | 505 +++ .../render/sky/CustomSkyboxRenderer.java | 356 ++ .../render/sodium/SodiumFrameContext.java | 22 + .../render/sodium/SodiumRenderBridge.java | 171 + .../SodiumSectionVisibilityProvider.java | 41 + .../sodium/SodiumShaderWorkarounds.java | 55 + .../render/sodium/SodiumTerrainInterop.java | 57 + .../SodiumTerrainInteropStatsSnapshot.java | 14 + .../sodium/SodiumVisibilityStatsSnapshot.java | 18 + .../terrain/CombatantChunkMeshAttributes.java | 20 + .../terrain/CombatantChunkMeshFormats.java | 16 + .../CombatantChunkVertexExtension.java | 16 + .../terrain/CombatantModelVertexType.java | 43 + .../terrain/CombatantTerrainVertex.java | 116 + .../loki/lovisual/runtime/ClientRuntime.java | 228 ++ .../lovisual/runtime/ClientRuntimeState.java | 17 + .../runtime/RuntimeCleanupReport.java | 98 + .../lovisual/runtime/RuntimeDiagnostics.java | 71 + .../loki/lovisual/runtime/RuntimeGate.java | 49 + .../runtime/RuntimeResumeParticipant.java | 14 + .../runtime/RuntimeShutdownContext.java | 44 + .../runtime/RuntimeShutdownParticipant.java | 14 + .../loader/JarReplacementCoordinator.java | 16 + .../lovisual/runtime/loader/LoaderBridge.java | 22 + .../PanicOnlyJarReplacementCoordinator.java | 27 + .../loader/RuntimeGateSourcePolicy.java | 27 + .../runtime/loader/RuntimeSourcePolicy.java | 16 + .../loader/StandardFabricLoaderBridge.java | 62 + .../java/dev/loki/lovisual/util/FastFps.java | 75 + .../loki/lovisual/util/LegacyHudNotifier.java | 28 + .../loki/lovisual/util/NarratorBlocker.java | 49 + .../dev/loki/lovisual/util/OmniItemUtils.java | 60 + .../lovisual/util/aiming/AimPointUtil.java | 113 + .../lovisual/util/aiming/RequestHandler.java | 101 + .../aiming/RestrictedSingleUseAction.java | 37 + .../lovisual/util/aiming/RotationManager.java | 405 ++ .../lovisual/util/aiming/RotationTarget.java | 96 + .../lovisual/util/aiming/RotationUtil.java | 149 + .../lovisual/util/aiming/data/Rotation.java | 142 + .../util/aiming/data/RotationDelta.java | 30 + .../util/aiming/data/RotationWithVector.java | 24 + .../aiming/features/MovementCorrection.java | 38 + .../processors/FailRotationProcessor.java | 229 ++ .../features/processors/LazyAimProcessor.java | 40 + .../processors/MovingRotationProcessor.java | 58 + .../processors/RotationProcessor.java | 27 + .../processors/ShakeAimProcessor.java | 50 + .../processors/SnapAssistAimProcessor.java | 45 + .../processors/anglesmooth/AngleSmooth.java | 41 + .../anglesmooth/FactorAngleSmooth.java | 59 + .../anglesmooth/NoneAngleSmooth.java | 39 + .../impl/AccelerationAngleSmooth.java | 245 ++ .../impl/InterpolationAngleSmooth.java | 115 + .../anglesmooth/impl/LinearAngleSmooth.java | 118 + .../anglesmooth/impl/SigmoidAngleSmooth.java | 85 + .../anglesmooth/impl/SmartAngleSmooth.java | 178 + .../anglesmooth/impl/SpookyAngleSmooth.java | 214 ++ .../util/aiming/point/PointInsideBox.java | 37 + .../util/aiming/point/PointTracker.java | 594 +++ .../preference/LeastDifferencePreference.java | 87 + .../aiming/preference/RotationPreference.java | 30 + .../aiming/raytrace/RotationRaytrace.java | 236 ++ .../util/block/BlockDeobfuscationScanner.java | 228 ++ .../BlockEspSodiumCandidateCollector.java | 177 + .../util/block/BlockObservationHub.java | 91 + .../util/block/BlockOutlineRenderer.java | 118 + .../util/block/BlockOutlineShapeMode.java | 26 + .../lovisual/util/block/BlockScanContext.java | 102 + .../lovisual/util/block/BlockScanMode.java | 35 + .../lovisual/util/block/BlockSearchUtil.java | 72 + .../util/block/RenderSectionBlockScanner.java | 42 + .../util/block/WorldSectionBlockScanner.java | 196 + .../lovisual/util/block/bed/BedBlockUtil.java | 134 + .../util/block/bed/BedwarsTeamColorUtil.java | 202 + .../lovisual/util/block/bed/SelfBedMode.java | 28 + .../util/block/bed/SelfBedTracker.java | 208 + .../util/block/placer/BlockPlacer.java | 486 +++ .../scaffold/ScaffoldBlockItemSelection.java | 103 + .../scaffold/ScaffoldFacePositionFactory.java | 546 +++ .../scaffold/ScaffoldInteractionTarget.java | 78 + .../ScaffoldInteractionTargetFinder.java | 47 + .../util/block/scaffold/ScaffoldLine.java | 41 + .../scaffold/ScaffoldMovementPlanner.java | 117 + .../scaffold/ScaffoldMovementPrediction.java | 217 ++ .../block/scaffold/ScaffoldPlacementGate.java | 89 + .../scaffold/ScaffoldPlacementTarget.java | 89 + .../block/scaffold/ScaffoldTargetFinder.java | 321 ++ .../lovisual/util/click/AttackPressing.java | 93 + .../lovisual/util/click/ClickHumanizer.java | 97 + .../lovisual/util/click/ClickScheduler.java | 161 + .../lovisual/util/combat/AntiBotTracker.java | 302 ++ .../loki/lovisual/util/combat/AttackUtil.java | 247 ++ .../util/combat/CombatRotationModeUtil.java | 101 + .../util/combat/CombatStrikeController.java | 212 ++ .../util/combat/LethalStrikeCalculator.java | 267 ++ .../util/combat/ProtocolAttackExecutor.java | 64 + .../util/combat/RubberHandUseUtil.java | 89 + .../util/combat/SprintController.java | 303 ++ .../util/combat/VulcanReachController.java | 721 ++++ .../protocol/ClientProtocolVersion.java | 14 + .../CombatProtocolHeuristicSource.java | 24 + .../protocol/CombatProtocolHeuristics.java | 164 + .../CombatProtocolHeuristicsConfig.java | 109 + .../util/combat/protocol/ProtocolUtil.java | 179 + .../combat/protocol/vfp/VfpCompatibility.java | 172 + .../protocol/vfp/VfpCompatibility1_8.java | 77 + .../loki/lovisual/util/entity/EagleUtil.java | 371 ++ .../lovisual/util/entity/FreecamEntity.java | 51 + .../simulation/BoatSimulationCache.java | 311 ++ .../simulation/MountedEntityPrediction.java | 79 + .../simulation/PositionExtrapolation.java | 53 + .../entity/simulation/PositionPredictor.java | 100 + .../loki/lovisual/util/input/KeyManager.java | 243 ++ .../dev/loki/lovisual/util/input/KeyUtil.java | 372 ++ .../loki/lovisual/util/item/EnchantMeta.java | 80 + .../lovisual/util/item/EnchantRegistry.java | 82 + .../loki/lovisual/util/item/EnchantUtil.java | 28 + .../dev/loki/lovisual/util/item/FoodUtil.java | 202 + .../lovisual/util/item/IllegalItemUtil.java | 66 + .../util/item/ItemRarityColorProvider.java | 25 + .../lovisual/util/item/RarityColorConfig.java | 75 + .../lovisual/util/item/RarityColorUtil.java | 53 + .../lovisual/util/item/TopEnchantUtil.java | 107 + .../loki/lovisual/util/logging/DebugLog.java | 207 + .../loki/lovisual/util/logging/DebugMode.java | 20 + .../lovisual/util/logging/ServerDumpUtil.java | 82 + .../lovisual/util/map/MapPlayersCache.java | 199 + .../lovisual/util/media/IMediaSession.java | 64 + .../loki/lovisual/util/media/MediaInfo.java | 59 + .../lovisual/util/media/MediaPlayerInfo.java | 47 + .../util/media/MediaSessionService.java | 591 +++ .../loki/lovisual/util/media/RepeatMode.java | 36 + .../util/media/impl/DummyMediaPlayerInfo.java | 30 + .../impl/linux/LinuxMediaPlayerInfo.java | 103 + .../media/impl/linux/LinuxMediaSession.java | 440 +++ .../util/media/impl/linux/dbus/Player.java | 61 + .../impl/win/WindowsMediaPlayerInfo.java | 167 + .../media/impl/win/WindowsMediaSession.java | 144 + .../util/network/BacktrackController.java | 443 +++ .../lovisual/util/network/BlinkManager.java | 315 ++ .../util/network/FakeLagController.java | 249 ++ .../lovisual/util/network/PacketSnapshot.java | 22 + .../lovisual/util/network/TransferOrigin.java | 13 + .../util/player/ElytraRecastUtil.java | 274 ++ .../player/EnvironmentMovementController.java | 87 + .../lovisual/util/player/InteractionUtil.java | 54 + .../lovisual/util/player/MovementUtil.java | 159 + .../util/player/NetworkStatsUtil.java | 142 + .../util/player/PlayerHealthResolver.java | 110 + .../util/player/PlayerSkinResolver.java | 168 + .../lovisual/util/player/PlayerSpeedUtil.java | 31 + .../util/player/ResetAttackCooldown.java | 20 + .../lovisual/util/player/ServerFlyUtils.java | 45 + .../player/effect/StatusEffectAccess.java | 54 + .../player/effect/StatusEffectHeuristics.java | 386 ++ .../player/effect/StatusEffectInference.java | 322 ++ .../player/effect/StatusEffectTracker.java | 271 ++ .../util/player/effect/StatusEffectView.java | 72 + .../inventory/FireworkUseController.java | 139 + .../player/inventory/InventoryActionKind.java | 20 + .../inventory/InventorySearchScope.java | 27 + .../util/player/inventory/InventorySwap.java | 1009 +++++ .../player/inventory/InventorySwapMode.java | 17 + .../player/inventory/InventorySwapPolicy.java | 27 + .../inventory/InventorySwapRequest.java | 109 + .../inventory/InventorySwapVisibility.java | 26 + .../util/player/inventory/SlotResult.java | 24 + .../simulation/LocalPlayerSimulation.java | 17 + .../simulation/PlayerMovementSimulation.java | 762 ++++ .../simulation/PlayerSimulationCache.java | 153 + .../simulation/RemotePlayerObservation.java | 187 + .../simulation/RemotePlayerSimulation.java | 61 + .../player/simulation/SimulatedInput.java | 138 + .../CydhranianProjectileAngleCalculator.java | 136 + .../util/projectile/PointFinding.java | 268 ++ .../PolynomialProjectileAngleCalculator.java | 53 + .../projectile/ProjectileAngleCalculator.java | 38 + .../util/projectile/ProjectileMath.java | 52 + .../projectile/ProjectilePredictionUtil.java | 180 + .../util/projectile/ProjectileTarget.java | 67 + .../ProjectileTargetPointFinder.java | 35 + .../util/projectile/SimulatedArrow.java | 117 + .../SituationalProjectileAngleCalculator.java | 34 + .../util/projectile/TrajectoryData.java | 128 + .../util/projectile/TrajectoryInfo.java | 92 + .../util/projectile/TrajectoryType.java | 32 + .../lovisual/util/proxy/ProxyBackend.java | 115 + .../loki/lovisual/util/proxy/ProxyConfig.java | 190 + .../loki/lovisual/util/proxy/ProxyEntry.java | 205 + .../util/proxy/ProxyHandshakeHandler.java | 356 ++ .../util/proxy/ProxyNettyInstaller.java | 37 + .../loki/lovisual/util/proxy/ProxyType.java | 22 + .../lovisual/util/pvp/CooldownRegistry.java | 63 + .../util/pvp/ItemCooldownRuleProvider.java | 28 + .../util/pvp/ItemCooldownSnapshot.java | 83 + .../lovisual/util/pvp/ItemUseCooldowns.java | 248 ++ .../loki/lovisual/util/pvp/PvpChatParser.java | 89 + .../lovisual/util/pvp/PvpOverlayParser.java | 60 + .../dev/loki/lovisual/util/pvp/PvpState.java | 170 + .../loki/lovisual/util/pvp/PvpTabParser.java | 27 + .../loki/lovisual/util/pvp/PvpTagSource.java | 14 + .../lovisual/util/pvp/PvpTargetState.java | 80 + .../lovisual/util/pvp/PvpTargetTracker.java | 91 + .../lovisual/util/pvp/PvpTextPatternUtil.java | 126 + .../loki/lovisual/util/pvp/PvpTracker.java | 142 + .../util/pvp/client/CooldownManager.java | 96 + .../util/pvp/client/CooldownsState.java | 29 + .../util/pvp/client/PendingUseTracker.java | 62 + .../opponents/OpponentCooldownManager.java | 58 + .../util/pvp/opponents/TotemPopCounter.java | 219 ++ .../util/pvp/opponents/TotemPopSnapshot.java | 28 + .../lovisual/util/raycast/RaycastUtil.java | 120 + .../resources/RenderResourceReadiness.java | 55 + .../util/resources/ResourceReloadHooks.java | 63 + .../lovisual/util/screen/ClientScreen.java | 34 + .../lovisual/util/screen/ScreenCatalog.java | 224 ++ .../util/session/MicrosoftSessionResult.java | 33 + .../session/MinecraftGameConfigHolder.java | 14 + .../lovisual/util/session/SessionChanger.java | 283 ++ .../util/session/SessionLoginData.java | 40 + .../microsoft/DeviceAuthPendingException.java | 18 + .../microsoft/MicrosoftAuthException.java | 33 + .../microsoft/MicrosoftAuthService.java | 431 +++ .../microsoft/MicrosoftDeviceCode.java | 53 + .../util/session/microsoft/MicrosoftJson.java | 52 + .../session/microsoft/MicrosoftTokens.java | 23 + .../session/microsoft/MinecraftProfile.java | 30 + .../util/session/microsoft/XboxToken.java | 33 + .../lovisual/util/target/TargetManager.java | 223 ++ .../lovisual/util/target/TargetingUtil.java | 190 + .../loki/lovisual/util/text/ChatNameUtil.java | 112 + .../lovisual/util/text/ClipboardUtil.java | 36 + .../lovisual/util/text/LegacyTextUtil.java | 262 ++ .../loki/lovisual/util/text/TextJsonUtil.java | 73 + .../lovisual/util/text/TextRenderUtil.java | 78 + .../lovisual/util/text/TextSelection.java | 140 + .../dev/loki/lovisual/util/time/Timer.java | 52 + .../lovisual/util/time/TimerController.java | 121 + .../lovisual/util/wav/ClickGuiSounds.java | 153 + .../lovisual/util/wav/CustomSoundEngine.java | 102 + .../loki/lovisual/util/wav/SourcePool.java | 80 + .../lovisual/util/wav/WavBufferCache.java | 52 + .../dev/loki/lovisual/util/wav/WavData.java | 24 + .../loki/lovisual/util/wav/WavDebugStats.java | 67 + .../dev/loki/lovisual/util/wav/WavLoader.java | 76 + .../dev/loki/lovisual/util/wav/WavSound.java | 48 + .../lovisual/util/wav/WavSoundHelper.java | 56 + .../util/world/ExplosionDamageUtil.java | 558 +++ .../src/main/native/mediainfo/CMakeLists.txt | 11 + .../src/main/native/mediainfo/main.cpp | 558 +++ .../src/main/native/mediainfo/main.h | 36 + .../assets/lovisual/font/comfortaa.ttf | Bin 0 -> 110148 bytes .../resources/assets/lovisual/font/icons.ttf | Bin 0 -> 34636 bytes .../resources/assets/lovisual/font/icons2.ttf | Bin 0 -> 16740 bytes .../assets/lovisual/font/iconsnur.ttf | Bin 0 -> 5960 bytes .../assets/lovisual/font/inter_bold.ttf | Bin 0 -> 316100 bytes .../assets/lovisual/font/inter_medium.ttf | Bin 0 -> 314712 bytes .../assets/lovisual/font/inter_regular.ttf | Bin 0 -> 309828 bytes .../assets/lovisual/font/iosevka-bold.ttf | Bin 0 -> 832808 bytes .../lovisual/font/iosevka-bolditalic.ttf | Bin 0 -> 884916 bytes .../assets/lovisual/font/iosevka-light.ttf | Bin 0 -> 1792840 bytes .../lovisual/font/iosevka-lightitalic.ttf | Bin 0 -> 882768 bytes .../assets/lovisual/font/iosevka-medium.ttf | Bin 0 -> 829984 bytes .../lovisual/font/iosevka-mediumitalic.ttf | Bin 0 -> 878012 bytes .../assets/lovisual/font/mediaplayer.ttf | Bin 0 -> 2520 bytes .../assets/lovisual/font/monsterrat.ttf | Bin 0 -> 198104 bytes .../assets/lovisual/font/msdf/comfortaa.json | 1 + .../assets/lovisual/font/msdf/comfortaa.png | Bin 0 -> 366213 bytes .../assets/lovisual/font/msdf/guiicons.json | 1 + .../assets/lovisual/font/msdf/guiicons.png | Bin 0 -> 180839 bytes .../assets/lovisual/font/msdf/inter_bold.json | 1 + .../assets/lovisual/font/msdf/inter_bold.png | Bin 0 -> 505527 bytes .../lovisual/font/msdf/inter_medium.json | 1 + .../lovisual/font/msdf/inter_medium.png | Bin 0 -> 488777 bytes .../lovisual/font/msdf/inter_regular.json | 1 + .../lovisual/font/msdf/inter_regular.png | Bin 0 -> 478042 bytes .../lovisual/font/msdf/iosevka-bold.json | 1 + .../lovisual/font/msdf/iosevka-bold.png | Bin 0 -> 243034 bytes .../font/msdf/iosevka-bolditalic.json | 1 + .../lovisual/font/msdf/iosevka-bolditalic.png | Bin 0 -> 324628 bytes .../lovisual/font/msdf/iosevka-medium.json | 1 + .../lovisual/font/msdf/iosevka-medium.png | Bin 0 -> 241542 bytes .../font/msdf/iosevka-mediumitalic.json | 1 + .../font/msdf/iosevka-mediumitalic.png | Bin 0 -> 319661 bytes .../lovisual/font/msdf/mainmenuicons.json | 1 + .../lovisual/font/msdf/mainmenuicons.png | Bin 0 -> 82168 bytes .../assets/lovisual/font/msdf/monsterrat.json | 1 + .../assets/lovisual/font/msdf/monsterrat.png | Bin 0 -> 396288 bytes .../assets/lovisual/font/msdf/onest_bold.json | 1 + .../assets/lovisual/font/msdf/onest_bold.png | Bin 0 -> 242653 bytes .../lovisual/font/msdf/onest_light.json | 1 + .../assets/lovisual/font/msdf/onest_light.png | Bin 0 -> 232568 bytes .../lovisual/font/msdf/onest_medium.json | 1 + .../lovisual/font/msdf/onest_medium.png | Bin 0 -> 247431 bytes .../lovisual/font/msdf/onest_regular.json | 1 + .../lovisual/font/msdf/onest_regular.png | Bin 0 -> 238697 bytes .../assets/lovisual/font/msdf/profont.json | 1 + .../assets/lovisual/font/msdf/profont.png | Bin 0 -> 71897 bytes .../assets/lovisual/font/msdf/richicons.json | 1 + .../assets/lovisual/font/msdf/richicons.png | Bin 0 -> 180102 bytes .../assets/lovisual/font/onest_bold.ttf | Bin 0 -> 121532 bytes .../assets/lovisual/font/onest_light.ttf | Bin 0 -> 114724 bytes .../assets/lovisual/font/onest_medium.ttf | Bin 0 -> 119284 bytes .../assets/lovisual/font/onest_regular.ttf | Bin 0 -> 118316 bytes .../assets/lovisual/font/profont.ttf | Bin 0 -> 56376 bytes .../assets/lovisual/font/vanilla_symbols.ttf | Bin 0 -> 26032 bytes .../assets/lovisual/font/weather_icons.ttf | Bin 0 -> 9772 bytes .../resources/assets/lovisual/lang/en_us.json | 3228 ++++++++++++++++ .../resources/assets/lovisual/lang/ru_ru.json | 3018 +++++++++++++++ .../assets/lovisual/shaders/arc_batch.frag | 104 + .../shaders/chamfered_rect_batch.frag | 79 + .../shaders/chamfered_rect_stroke_batch.frag | 73 + .../assets/lovisual/shaders/circle_batch.frag | 59 + .../lovisual/shaders/core/entity_dither.fsh | 95 + .../lovisual/shaders/core/entity_dither.vsh | 84 + .../assets/lovisual/shaders/damage_tint.frag | 43 + .../assets/lovisual/shaders/damage_tint.vert | 23 + .../lovisual/shaders/depth_of_field.frag | 272 ++ .../lovisual/shaders/gui_texture_lookup.frag | 39 + .../assets/lovisual/shaders/hand_glass.frag | 298 ++ .../lovisual/shaders/hand_metallic.frag | 167 + .../assets/lovisual/shaders/hand_smoke.frag | 168 + .../assets/lovisual/shaders/heat_fx.frag | 57 + .../complementary_reimagined/r5/common.patch | 6 + .../r5/composite1.patch | 93 + .../r5/composite4.patch | 32 + .../r5/deferred1.patch | 25 + .../complementary_reimagined/r5/fog.patch | 56 + .../r5/lighting.patch | 40 + .../complementary_reimagined/r5/manifest.json | 68 + .../complementary_reimagined/r5/water.patch | 28 + .../lovisual/shaders/iris-patches/index.json | 7 + .../iris-patches/photon/v1_3b/c0_vl.patch | 51 + .../photon/v1_3b/c1_blend_layers.patch | 37 + .../iris-patches/photon/v1_3b/lighting.patch | 31 + .../iris-patches/photon/v1_3b/manifest.json | 51 + .../photon/v1_3b/motion_blur.patch | 38 + .../shaders/menu_background_aurora.frag | 74 + .../shaders/menu_background_waves.frag | 52 + .../assets/lovisual/shaders/metallic.frag | 62 + .../assets/lovisual/shaders/motion_blur.frag | 177 + .../lovisual/shaders/orbiz_ring_batch.frag | 186 + .../assets/lovisual/shaders/portal_rift.frag | 58 + .../assets/lovisual/shaders/pos_color.frag | 22 + .../assets/lovisual/shaders/pos_color.vert | 30 + .../lovisual/shaders/pos_color_fog.frag | 62 + .../lovisual/shaders/pos_color_fog.vert | 43 + .../shaders/pos_color_rect_params.vert | 29 + .../shaders/pos_color_rect_params2.vert | 32 + .../shaders/pos_color_skybox_shader.vert | 33 + .../shaders/pos_local_color_rect_params.vert | 32 + .../shaders/pos_local_color_rect_params2.vert | 35 + .../shaders/pos_local_color_rect_params5.vert | 44 + .../pos_local_color_rect_params5_generic.vert | 44 + .../lovisual/shaders/pos_tex_color.frag | 25 + .../lovisual/shaders/pos_tex_color.vert | 32 + .../shaders/pos_tex_color_params2.vert | 32 + .../shaders/pos_tex_color_rect_params.vert | 32 + .../shaders/pos_tex_color_rect_params2.vert | 35 + .../shaders/pos_tex_color_sky_fog.frag | 71 + .../shaders/pos_tex_color_sky_fog.vert | 48 + .../pos_tex_color_sky_fog_additive.frag | 67 + .../lovisual/shaders/pos_tex_color_tint.frag | 73 + .../pos_tex_local_color_rect_params.vert | 35 + .../pos_tex_local_color_rect_params2.vert | 38 + .../assets/lovisual/shaders/post_fx.frag | 147 + .../lovisual/shaders/radial_glow_batch.frag | 56 + .../lovisual/shaders/reimagined_skybox.frag | 492 +++ .../lovisual/shaders/rounded_rect_batch.frag | 62 + .../shaders/rounded_rect_corners_batch.frag | 84 + .../shaders/rounded_rect_glow_batch.frag | 49 + .../shaders/rounded_rect_shadow_batch.frag | 63 + .../rounded_rect_soft_shadow_batch.frag | 50 + .../rounded_rect_stroke_angular_batch.frag | 108 + .../shaders/rounded_rect_stroke_batch.frag | 81 + .../rounded_rect_stroke_corners_batch.frag | 101 + .../shaders/rounded_rect_tex_batch.frag | 62 + .../shaders/rounded_rect_tex_mask_batch.frag | 62 + .../lovisual/shaders/shader_esp_gradient.frag | 49 + .../lovisual/shaders/shader_esp_shadow.frag | 64 + .../lovisual/shaders/shader_esp_smoke.frag | 105 + .../assets/lovisual/shaders/sky_sun.frag | 41 + .../lovisual/shaders/sleep_overlay.frag | 44 + .../shaders/sodium/block_layer_opaque.fsh | 98 + .../shaders/sodium/block_layer_opaque.vsh | 118 + .../assets/lovisual/shaders/svg_msdf.frag | 35 + .../assets/lovisual/shaders/text.frag | 25 + .../assets/lovisual/shaders/text.vert | 32 + .../lovisual/shaders/text_liquid_glass.frag | 83 + .../shaders/text_liquid_glass_msdf.frag | 104 + .../assets/lovisual/shaders/text_msdf.frag | 38 + .../assets/lovisual/shaders/ui_blur.frag | 47 + .../lovisual/shaders/ui_blur_batch.frag | 76 + .../shaders/ui_blur_batch_corners.frag | 90 + .../shaders/ui_liquid_glass_batch.frag | 290 ++ .../shaders/ui_rounded_fill_smoke_batch.frag | 138 + .../lovisual/shaders/ui_shape_batch.frag | 107 + .../assets/lovisual/shaders/wide_line.vert | 41 + .../lovisual/shaders/world_decal_sdf.frag | 59 + .../lovisual/sounds/disable/disable1.wav | Bin 0 -> 94456 bytes .../lovisual/sounds/disable/disable2.wav | Bin 0 -> 39802 bytes .../lovisual/sounds/disable/disable3.wav | Bin 0 -> 141032 bytes .../assets/lovisual/sounds/enable/enable1.wav | Bin 0 -> 100576 bytes .../assets/lovisual/sounds/enable/enable2.wav | Bin 0 -> 30934 bytes .../assets/lovisual/sounds/enable/enable3.wav | Bin 0 -> 134628 bytes .../assets/lovisual/sounds/gui/guibinding.wav | Bin 0 -> 85522 bytes .../lovisual/sounds/gui/guibindingnull.wav | Bin 0 -> 114738 bytes .../lovisual/sounds/gui/guibindingstart.wav | Bin 0 -> 125458 bytes .../lovisual/sounds/gui/guibindreset.wav | Bin 0 -> 29642 bytes .../lovisual/sounds/gui/guichangemode.wav | Bin 0 -> 12940 bytes .../lovisual/sounds/gui/guimodulehover.wav | Bin 0 -> 49602 bytes .../assets/lovisual/sounds/gui/guino.wav | Bin 0 -> 789148 bytes .../assets/lovisual/sounds/gui/guiscroll.wav | Bin 0 -> 4500 bytes .../lovisual/sounds/gui/guislidermove.wav | Bin 0 -> 2106 bytes .../assets/lovisual/sounds/gui/guiyes.wav | Bin 0 -> 271950 bytes .../lovisual/sounds/gui/moduleclose.wav | Bin 0 -> 186078 bytes .../lovisual/sounds/gui/moduleonclose.wav | Bin 0 -> 99550 bytes .../lovisual/sounds/gui/moduleonopen.wav | Bin 0 -> 153678 bytes .../assets/lovisual/sounds/gui/moduleopen.wav | Bin 0 -> 155886 bytes .../assets/lovisual/sounds/hitsounds/bell.wav | Bin 0 -> 152168 bytes .../assets/lovisual/sounds/hitsounds/bonk.wav | Bin 0 -> 78458 bytes .../lovisual/sounds/hitsounds/bubble.wav | Bin 0 -> 41550 bytes .../lovisual/sounds/hitsounds/crime.wav | Bin 0 -> 258194 bytes .../lovisual/sounds/hitsounds/critical.wav | Bin 0 -> 96846 bytes .../assets/lovisual/sounds/misc/friendadd.wav | Bin 0 -> 265382 bytes .../lovisual/sounds/misc/friendremove.wav | Bin 0 -> 93730 bytes .../assets/lovisual/sounds/misc/orthodox.wav | Bin 0 -> 163690 bytes .../assets/lovisual/sounds/misc/warning.wav | Bin 0 -> 142926 bytes .../resources/assets/lovisual/svg/.gitkeep | 0 .../assets/lovisual/svg/accessibility.svg | 1 + .../lovisual/svg/arrow-up-from-line.svg | 1 + .../assets/lovisual/svg/brain-cog.svg | 1 + .../resources/assets/lovisual/svg/brush.svg | 1 + .../resources/assets/lovisual/svg/check.svg | 1 + .../assets/lovisual/svg/clipboard.svg | 1 + .../assets/lovisual/svg/columns-3-cog.svg | 1 + .../resources/assets/lovisual/svg/cross.svg | 1 + .../resources/assets/lovisual/svg/diff.svg | 1 + .../assets/lovisual/svg/download.svg | 1 + .../assets/lovisual/svg/ellipsis.svg | 1 + .../assets/lovisual/svg/external-link.svg | 1 + .../assets/lovisual/svg/folder-archive.svg | 1 + .../assets/lovisual/svg/folder-check.svg | 1 + .../assets/lovisual/svg/folder-clock.svg | 1 + .../assets/lovisual/svg/folder-closed.svg | 1 + .../assets/lovisual/svg/folder-cog.svg | 1 + .../assets/lovisual/svg/folder-down.svg | 1 + .../assets/lovisual/svg/folder-open.svg | 1 + .../assets/lovisual/svg/folder-output.svg | 1 + .../assets/lovisual/svg/folder-pen.svg | 1 + .../assets/lovisual/svg/folder-plus.svg | 1 + .../assets/lovisual/svg/folder-sync.svg | 1 + .../assets/lovisual/svg/folder-tree.svg | 1 + .../assets/lovisual/svg/folder-x.svg | 1 + .../resources/assets/lovisual/svg/folder.svg | 1 + .../assets/lovisual/svg/heart-plus.svg | 1 + .../assets/lovisual/svg/heart-pulse.svg | 1 + .../resources/assets/lovisual/svg/minus.svg | 1 + .../resources/assets/lovisual/svg/package.svg | 1 + .../assets/lovisual/svg/paintbrush.svg | 1 + .../resources/assets/lovisual/svg/palette.svg | 1 + .../assets/lovisual/svg/repeat-1.svg | 1 + .../assets/lovisual/svg/repeat-off.svg | 1 + .../resources/assets/lovisual/svg/repeat.svg | 1 + .../resources/assets/lovisual/svg/save.svg | 1 + .../assets/lovisual/svg/shield-user.svg | 1 + .../resources/assets/lovisual/svg/swords.svg | 1 + .../assets/lovisual/svg/toggle-left.svg | 1 + .../assets/lovisual/svg/toggle-right.svg | 1 + .../assets/lovisual/svg/toggle-thumb.svg | 1 + .../assets/lovisual/svg/toggle-track.svg | 1 + .../resources/assets/lovisual/svg/trash-2.svg | 1 + .../assets/lovisual/svg/tree-deciduous.svg | 1 + .../assets/lovisual/svg/tree-palm.svg | 1 + .../assets/lovisual/svg/tree-pine.svg | 1 + .../resources/assets/lovisual/svg/trees.svg | 1 + .../assets/lovisual/svg/user-check.svg | 1 + .../assets/lovisual/svg/user-cog.svg | 1 + .../assets/lovisual/svg/user-minus.svg | 1 + .../assets/lovisual/svg/user-pen.svg | 1 + .../assets/lovisual/svg/user-plus.svg | 1 + .../resources/assets/lovisual/svg/user.svg | 1 + .../assets/lovisual/svg/vanilla/u1f30a.svg | 27 + .../assets/lovisual/svg/vanilla/u1f356.svg | 39 + .../assets/lovisual/svg/vanilla/u1f3a3.svg | 46 + .../assets/lovisual/svg/vanilla/u1f3f9.svg | 28 + .../assets/lovisual/svg/vanilla/u1f514.svg | 36 + .../assets/lovisual/svg/vanilla/u1f525.svg | 36 + .../assets/lovisual/svg/vanilla/u1f531.svg | 51 + .../assets/lovisual/svg/vanilla/u1f9ea.svg | 34 + .../assets/lovisual/svg/vanilla/u274c.svg | 26 + .../main/resources/assets/lovisual/svg/x.svg | 1 + .../assets/lovisual/textures/bloom.png | Bin 0 -> 2347 bytes .../lovisual/textures/glow_particle.png | Bin 0 -> 4430 bytes .../lovisual/textures/hud/elements/arrow.png | Bin 0 -> 34910 bytes .../lovisual/textures/hud/elements/arrow2.png | Bin 0 -> 5137 bytes .../lovisual/textures/hud/elements/arrow3.png | Bin 0 -> 84580 bytes .../lovisual/textures/hud/elements/arrow4.png | Bin 0 -> 127386 bytes .../textures/hud/elements/capture.png | Bin 0 -> 267888 bytes .../lovisual/textures/hud/elements/coords.png | Bin 0 -> 30680 bytes .../lovisual/textures/hud/elements/fps.png | Bin 0 -> 14504 bytes .../lovisual/textures/hud/elements/memory.png | Bin 0 -> 9308 bytes .../lovisual/textures/hud/elements/ping.png | Bin 0 -> 9945 bytes .../lovisual/textures/hud/elements/server.png | Bin 0 -> 19218 bytes .../textures/hud/elements/speedometer.png | Bin 0 -> 23867 bytes .../lovisual/textures/hud/elements/tps.png | Bin 0 -> 24076 bytes .../lovisual/textures/particles/bubble.png | Bin 0 -> 202987 bytes .../lovisual/textures/particles/cross.png | Bin 0 -> 257601 bytes .../lovisual/textures/particles/cube.png | Bin 0 -> 333453 bytes .../textures/particles/default_circle.png | Bin 0 -> 83614 bytes .../lovisual/textures/particles/feather.png | Bin 0 -> 287245 bytes .../textures/particles/funnel/distortion.png | Bin 0 -> 180257 bytes .../textures/particles/funnel/funnel_eye.png | Bin 0 -> 84275 bytes .../lovisual/textures/particles/glow.png | Bin 0 -> 286769 bytes .../textures/particles/jump_circle.png | Bin 0 -> 53794 bytes .../lovisual/textures/particles/mcross.png | Bin 0 -> 209885 bytes .../lovisual/textures/particles/moon.png | Bin 0 -> 332001 bytes .../assets/lovisual/textures/particles/p1.png | Bin 0 -> 10686 bytes .../lovisual/textures/particles/p10.png | Bin 0 -> 7334 bytes .../lovisual/textures/particles/p11.png | Bin 0 -> 20485 bytes .../lovisual/textures/particles/p12.png | Bin 0 -> 10155 bytes .../assets/lovisual/textures/particles/p2.png | Bin 0 -> 9955 bytes .../assets/lovisual/textures/particles/p3.png | Bin 0 -> 12001 bytes .../assets/lovisual/textures/particles/p4.png | Bin 0 -> 13059 bytes .../assets/lovisual/textures/particles/p5.png | Bin 0 -> 11817 bytes .../assets/lovisual/textures/particles/p7.png | Bin 0 -> 12169 bytes .../assets/lovisual/textures/particles/p8.png | Bin 0 -> 14178 bytes .../assets/lovisual/textures/particles/p9.png | Bin 0 -> 8136 bytes .../lovisual/textures/particles/spark.png | Bin 0 -> 292282 bytes .../lovisual/textures/particles/star.png | Bin 0 -> 347710 bytes .../lovisual/textures/particles/triangle.png | Bin 0 -> 344819 bytes .../assets/lovisual/textures/timers/arrow.png | Bin 0 -> 2585 bytes .../assets/lovisual/textures/timers/pearl.png | Bin 0 -> 226 bytes .../lovisual/textures/timers/potion.png | Bin 0 -> 318 bytes .../assets/lovisual/textures/timers/tnt.png | Bin 0 -> 208 bytes .../lovisual/textures/timers/trident.png | Bin 0 -> 16620 bytes .../assets/lovisual/ui/api/actions.d.ts | 22 + .../assets/lovisual/ui/api/assets.d.ts | 32 + .../ui/api/clickgui/settings_panel.js | 75 + .../lovisual/ui/api/clickgui/theme_preview.js | 210 + .../ui/api/hud/draggable/compact_stat.js | 357 ++ .../ui/api/hud/draggable/inventory_panel.js | 95 + .../lovisual/ui/api/hud/draggable/itemizer.js | 121 + .../ui/api/hud/draggable/list_panel.js | 159 + .../ui/api/hud/draggable/panel_base.js | 407 ++ .../ui/api/hud/draggable/triangulator.js | 241 ++ .../ui/api/hud/static/custom_health_bar.js | 266 ++ .../ui/api/hud/static/dynamic_island.js | 291 ++ .../ui/api/hud/static/tooltip_panel.js | 94 + .../assets/lovisual/ui/api/runtime.d.ts | 34 + .../assets/lovisual/ui/api/theme.d.ts | 15 + .../resources/assets/lovisual/ui/api/ui.d.ts | 548 +++ .../resources/assets/lovisual/ui/api/ui.js | 299 ++ .../src/main/resources/fabric.mod.json | 32 + .../main/resources/javet/natives/README.txt | 7 + .../src/main/resources/lovisual.mixins.json | 191 + .../natives/win/MediaPlayerInfo.dll | Bin 0 -> 84480 bytes .../engine/svg/SvgMsdfPreprocessor.java | 263 ++ .../command/CommandDiscoveryTest.java | 62 + .../impl/XaeroWaypointCommandTest.java | 30 + .../module/modules/player/NoInteractTest.java | 53 + .../script/JavetUiScriptEngineTest.java | 37 + .../scaffold/ScaffoldPlacementGateTest.java | 305 ++ .../CommandEncoderDiagnosticsMixin.java | 91 + .../RenderPassWrapperDiagnosticsMixin.java | 83 + .../lovisual/mixins/VulkanBackendMixin.java | 16 + .../VulkanCheckpointDiagnosticsMixin.java | 35 + .../VulkanCommandEncoderDiagnosticsMixin.java | 94 + .../mixins/VulkanDebugMessageMixin.java | 19 + .../mixins/VulkanDeviceDiagnosticsMixin.java | 30 + .../mixins/VulkanQueueSubmissionMixin.java | 27 + .../VulkanRenderPassDiagnosticsMixin.java | 289 ++ .../VulkanRenderPipelineDiagnosticsMixin.java | 37 + .../vulkan/debug/VulkanCrashDiagnostics.java | 326 ++ .../lovisual-vulkan-debug.mixins.json | 22 + .../tools/msdf/GenerateSvgMsdf.ps1 | 168 + .../tools/msdf/msdf-atlas-gen.exe | Bin 0 -> 1566720 bytes .../msdf/msdf-atlas-gen/msdf-atlas-gen.exe | Bin 0 -> 1566720 bytes .../msdfgen-1.13-win64/msdfgen/example.bat | 1 + .../msdfgen-1.13-win64/msdfgen/msdfgen.exe | Bin 0 -> 1803776 bytes combatant-client-26.2/tools/msdf/msdfgen.exe | Bin 0 -> 1803776 bytes 1629 files changed, 238620 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 TODO.md create mode 100644 combatant-client-26.2/.gitignore create mode 100644 combatant-client-26.2/LICENSE create mode 100644 combatant-client-26.2/README.md create mode 100644 combatant-client-26.2/build.gradle create mode 100644 combatant-client-26.2/gradle.properties create mode 100644 combatant-client-26.2/gradle/wrapper/gradle-wrapper.properties create mode 100644 combatant-client-26.2/lombok.config create mode 100644 combatant-client-26.2/settings.gradle create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/features/command/impl/ProfilerCommand.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/mixins/ProfilersMixin.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevFrameStutterProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevGlSyncTracker.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevJfrProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevProfilerPhase.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderCostProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderFrameProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler2D.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler3D.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevSamplingProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyGpuProfiler.java create mode 100644 combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyProfiler.java create mode 100644 combatant-client-26.2/src/dev/resources/lovisual-dev.mixins.json create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonDescriptor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonIssue.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRegistration.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRenderPipelineManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStateStore.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStatus.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ClickGuiSectionManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonContextImpl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonRuntimeContextImpl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualModuleLoadContextImpl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionContextImpl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddon.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonRuntimeContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualModuleLoadContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiSection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/client/LoVisualClientApi.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/LoVisualModuleExtension.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/ModuleExtensionContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessCallback.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderCallback.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderPipelineBuilder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderStage.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroMinimapCompat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroWorldMapCompat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroCommandWaypointCompat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointStore.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Event.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventBus.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventHandler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Events.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/UsedImplicitly.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/AttackEntityEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/BlinkPacketEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CombatProtocolBossbarEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CrosshairTargetUpdateEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventBreakBlock.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventCollision.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPostSync.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPushOutOfBlocks.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventSync.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventTargetChanged.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/FireworkEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/GameTickEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/I18nPreflightCollectEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeyInputEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeybindIsPressedEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapModifyEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/MovementInputEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PacketEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerJumpEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerMoveEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerSafeWalkEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepSuccessEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerVelocityStrafe.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PostPlayerUpdateEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpChatEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpOverlayEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpTabEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RenderPrewarmCollectEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RotationUpdateEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/SprintControlEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountAuthType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountEntry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/SkinManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/ClientCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandMetadata.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandOutput.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/VanillaChatRuntimeCleanup.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/AddonsCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/BindCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CommandUtils.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ConfigCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CoordinatesCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HelpCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HideCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/IrisCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ModulesCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/PingCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RelationCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RuntimeCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ServerInfoCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ToggleCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/TpsCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/UsernameCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/XaeroWaypointCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatHoverCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatMessageGrouping.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatSearch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStore.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStoreManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatHoverUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatLine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/CommandSuggestorBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiEditorScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiPickerScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiSearch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/editor/ClickGuiTextEditorState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuCategory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/SettingsGuiPalette.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/category/CategoryComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleSettingsWindow.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/SettingsPanelComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/BackgroundComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/CategoryContainerComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/SearchComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/StatusRender.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/OnlineRelationPlayerPickerComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationPlayerCardComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationsComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeEditorPreviewComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/render/LayoutRender2D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/ClickGuiPickerState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalog.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalogFactory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerEntryData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/protocol/CombatProtocolHeuristicsEditorState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ClickGuiSection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ModulesSection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/SettingsSection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/settings/SettingsTabRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/BooleanSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorPresetStore.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSettingNoAlpha.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ConfigColorSettingNoAlpha.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/CooldownRulesSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/FunctionBindSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/GroupSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nDuplicateScanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightContributors.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/KeyBindSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModeSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModulesSettingsSkin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/PickerDetailOwner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ProtocolHeuristicsSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/Setting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingFactory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderSurface.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRendererBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SliderSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextEditorOwner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextListSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextSetting.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingsSkin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiHintOverlay.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiI18n.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiMath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiRichTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/AbstractHudElement.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/BaseHudElement.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/CooldownRender.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorX.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorY.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementAutoLoader.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementRegister.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElements.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudGlobalConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderSpace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudTextEffects.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/WidgetAnchorSide.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElement.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElementRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableLayoutValue.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Admins.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Armor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/BetterChat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Cooldowns.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Coordinates.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Fps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/GameTime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudNotifier.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudPanelLayoutModes.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Inventory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Itemizer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Keybinds.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/MediaPlayer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Memory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ModuleList.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Ping.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Potions.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Radar.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Scoreboard.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedInventoryHudPanel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedListHudPanel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedTriangulatorHudPanel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SpeedBps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SystemTime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TargetHud.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TimerIndicator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Tps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Triangulator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementBootstrap.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterButtons.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterTooltips.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomBar.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHealthBar.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHotbar.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/DynamicIsland.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/SwapTooltip.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tab/CustomTabList.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tab/TabListAnimator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tab/TabListModel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tab/TabRichTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/script/CompactHudStatModel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/script/HudScriptLayouts.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/script/ScriptableHudStatWidget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/script/ScriptedCompactHudStatRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/mainmenu/LoVisualAddonManagerScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/mainmenu/LoVisualAltManagerScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/mainmenu/LoVisualMainMenuScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/mainmenu/LoVisualProxyManagerScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/HudPhase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/Module.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleActivationSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleAutoLoader.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleCategory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleRegister.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/ModuleStateListener.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/Modules.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/Notifier.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/RuntimeControlModule.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/WorldPhase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AttributeSwap.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AutoAnchor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AutoAttack.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AutoBed.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AutoBow.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/AutoCrystal.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Backtrack.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Criticals.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/ElytraTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Hitbox.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/KTLeave.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/KillAura.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/MaceKill.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/ProjectilePuncher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/PvpCooldowns.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Reach.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/RubberHand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/SpearAssist.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/TPSSync.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorActionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorDamageCandidate.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorDamageRules.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorEntityBlocker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorInteractionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorPlacementMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorPlanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autoanchor/AutoAnchorRenderUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedActionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedDamageCandidate.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedDamageRules.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedEntityBlocker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedInteractionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedPlacementMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedPlanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autobed/AutoBedRenderUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalAttempt.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalBaseEvaluator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalBasePlanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalCrystalData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalDamageCandidate.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalDamageRules.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalEntityBlocker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalHand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalInteractionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalPlaceData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalPlaceScanDebug.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalPlaceValidationResult.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalPlacementMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalPlacementPlanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalRenderUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalSelectionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/autocrystal/AutoCrystalTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/BetterMinecraft.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/ClickGui.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/DefineTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/FakePlayer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/HitSounds.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/MessageFilter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/NoSound.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/Panic.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/AirJump.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/AutoDodge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/ElytraFly.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/ElytraHelper.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Flight.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Freeze.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/InventoryMove.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/NoFall.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/NoPush.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/NoStun.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Parkour.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Phase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/ReverseStep.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/SafeWalk.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Speed.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Sprint.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Step.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Strafe.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/SuperFirework.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/TargetStrafe.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Timer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/Velocity.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/movement/WindJump.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/AirPlace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/AutoArmor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/AutoEat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/AutoTool.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/AutoTotem.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/ClickPearl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/FakeLag.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/NoDelay.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/NoInteract.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/Scaffold.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/ShitDropper.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/player/XCarry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/AspectRatio.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BedwarsESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BlockESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BlockHighlight.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/CameraClip.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Chams.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Crosshair.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/DamageTint.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/DropESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/FovControl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Freecam.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/FullBright.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/HitEffect.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/HoleESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/JumpCircles.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/KillEffect.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/MotionBlur.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/NameTags.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/NoRender.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/PostFX.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Predictions.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ReimaginedVisual.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/SeeInvisibles.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/SoundESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/TargetESP.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/TazikHat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/TotemFX.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Tracers.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Trails.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ViewModel.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/WorldParticles.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/WorldTweaks.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Zoom.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/CategoryRules.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/CategoryService.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/CategoryType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/EntityFilters.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/PlayerRelations.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/StaffData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/StaffHeuristicsConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/StaffHeuristicsMatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/relations/StaffTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/security/BackdoorProtection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/security/FilteredTranslatableTextContent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/theme/EditableClickGuiTheme.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/theme/Theme.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/theme/ThemeStore.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/features/theme/Themes.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IEntity.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IGlBackendInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IGpuDevice.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IGuiGraphics.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IItemEntityRenderState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/ILocalPlayer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IMsaaDevice.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IMsaaTexture.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IPlayerAttackCooldown.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IRenderPipeline.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixininterface/IRoundedHitbox.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AbstractClientPlayerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AbstractContainerScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AbstractSelectionListMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AbstractSliderButtonMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AbstractWidgetMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/AtmosphericFogEnvironmentMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/BatchingRenderCommandQueueMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/BlockCollisionSpliteratorMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/BlockMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/BossHealthOverlayMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/BufferManagerDefaultBufferManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ButtonWidgetTextMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/CameraMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ChatComponentMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ChatScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ClientClockManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ClientCommonPacketListenerImplMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ClientLevelMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ClientPacketListenerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/CommandEncoderMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ConnectionMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ContextualBarMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/CycleButtonMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/DebugHudMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/DynamicUniformStorageMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/EntityMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/EquipmentLayerRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ExperienceBarMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/FireworkRocketEntityMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/FlowingFluidMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/FogRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/FontManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GameRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GameRendererViewEffectsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GlDeviceBackendMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GlStateManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GlTextureMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GpuDeviceMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GpuSurfaceMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GuiGraphicsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GuiMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GuiRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/GuiScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/HoneyBlockMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/InputMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemCooldownsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemEntityRenderStateMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemEntityRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemFeatureRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemInHandRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemStackMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/JoinMultiplayerScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/JumpBarMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/KeyMappingMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/KeyboardInputMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/KeyboardMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LavaFogModifierMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LevelExtractorMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LevelMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LevelRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LibraryMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LightmapMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LightmapRenderStateExtractorMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LivingEntityMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LivingEntityRenderStateMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LivingEntityRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LocalPlayerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LocatorBarMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MinecraftMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MouseMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MultiPlayerGameModeMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/NarratorManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/OptionsNarratorMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ParticleManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PauseScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PlayerEntityMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PlayerInfoMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PlayerInventoryMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PlayerTabOverlayMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/PowderSnowFogModifierMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ProjectileUtilMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/RecipeBookScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/RenderPassMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/RenderPipelineMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ScreenEffectRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ServerboundUseItemPacketMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ShaderLoaderMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/SkyRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/SlimeBlockMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/SpriteIconButtonCenteredIconMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/SpriteIconButtonTextAndIconMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/TitleScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ToastManagerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/TooltipRenderUtilMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanCommandEncoderMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanDeviceMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanGpuTextureMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanGpuTextureViewMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanRenderPassMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/VulkanRenderPipelineMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/WaterFogEnvironmentMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/BossHealthOverlayAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ChatInputSuggestorAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ChatScreenAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ChatScreenSuggestorAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ClickableWidgetAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ClientLevelAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ClientboundExplodePacketAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ClientboundSetEntityMotionPacketAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/CyclingButtonWidgetAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/EffectParticleEffectAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/EntityAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/EntityInvoker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/EntityRenderManagerAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/EntityRendererAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/FireworksComponentAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/GameRendererAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/GlTextureInvoker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/GpuDeviceAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/InGameOverlayRendererAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/InputAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ItemCooldownEntryAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ItemCooldownManagerAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ItemRenderStateLayerAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/LevelExtractorAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/LevelRendererAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/LivingEntityAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/LivingEntityRenderStateAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/LocalPlayerAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/MinecraftAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/MultiPlayerGameModeAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/PersistentProjectileEntityAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/PlayerInventoryAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/PlayerTabOverlayAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/RenderLayerInvokerAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ServerboundInteractPacketAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ServerboundMovePlayerPacketAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ShaderLoaderDefinitionsAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/ShaderSourceKeyAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/SliderWidgetAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/SlotAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/SuggestionWindowAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/TextFieldWidgetAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/TextIconButtonWidgetAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/accessors/WorldAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisCommonUniformsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisGameRendererInteropMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisHandRendererAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisHandRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisIncludeProcessorMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisRenderingPipelineFinalizeMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisShaderPackLoadMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/iris/IrisVanillaHandInteropMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/moreculling/CullingUtilsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/AbstractSignEditScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/AnvilScreenMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/ClientLoginNetworkHandlerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/GameOptionsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/NetworkUtilsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/TranslatableTextContentMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/security/TranslationStorageMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumBlockRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumBlockVisibilityMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumChunkBuilderMeshingTaskMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumChunkMeshFormatsMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumChunkVertexMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumShaderChunkRendererMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumVertexConsumerTrackerMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/sodium/SodiumWorldRendererFreecamMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/minimap/AbstractWaypointRenderProviderMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/worldmap/WaypointRenderProviderMixin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/worldmap/WorldMapWaypointAccessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/CombatantEntityRenderTypes.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/ShaderEspRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/ViewObstructionFadeContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/ViewObstructionFadeState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/compat/EntityCullingCompat.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/effects/NetherPortalRiftPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/effects/SleepOverlayPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/CombatantRenderEngineBootstrap.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/Projection2D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/RenderState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/Texture.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/TextureStorage.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedClockText.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedGlyphSlot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedRenderColors.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedTextLine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedTextStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimatedTextTransition.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/animation/AnimationUtility.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/color/ColorBlendMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/color/ColorEffects.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/color/ColorUtils.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/color/RenderColor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/RenderCommandBuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiBatchBackendCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiCommandBuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiCommandKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiCommandStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiEffectCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiEffectRegionCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiItemCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiPathCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiPrimitiveCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiShapeCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiTextCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiTextureCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/UiTextureDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/command/WorldCommandBuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/compat/immediatelyfast/ImmediatelyFastBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/compat/immediatelyfast/ImmediatelyFastRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/compat/immediatelyfast/ImmediatelyFastRuntimeSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/CameraContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/CombatantRenderSystem.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/CombatantWorldMatrices.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/FrameLifecycle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/FramebufferContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/RenderFrameContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/RenderPhase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/RenderPhaseScope.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/ViewportContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/DepthProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/FogProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/LightPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/VanillaWorldFogProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/VisibilityProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/core/policy/VisibilityQuery.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/debug/RenderThread2DDebugRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/depth/PreTranslucentDepth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/depth/WorldSceneDepth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/framegraph/CombatantFrameGraph.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/framegraph/PassScheduler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/framegraph/RenderPassNode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/guard/LegacyRenderPath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/guard/RenderArchitectureGuard.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/guard/RenderBoundary.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/guard/RenderBoundaryAudit.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/guard/RenderBoundaryExempt.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/light/LightmapState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/math/ColorMath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/math/HudScale.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/math/RenderMath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/msaa/MsaaFramebuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/msaa/MsaaResolveUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/msaa/MsaaTextureRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/msaa/MsaaWorldTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/CombatantRenderPipelines.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/DepthTestFunction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/ExtendedRenderPipelineBuilder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/DepthOfFieldDepthSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/DepthOfFieldQuality.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/MenuBackgroundRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/MotionBlurMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/MotionBlurResources.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/PostProcessContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/PostProcessManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/PostProcessPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/HistoryBufferManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/LegacyPostProcessGraphPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/PostProcessGraph.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/PostProcessGraphPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/PostProcessGraphResources.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/PostProcessResolution.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/graph/PostProcessResource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/prewarm/RenderPrewarmContributors.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/prewarm/RenderPrewarmManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/DevProfilerBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/FrameStutterProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/GlSyncTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/JfrProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/ProfilerLog.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/ProfilerPhase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/ProfilerSettings.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/RenderCostProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/RenderFrameProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/RenderProfiler2D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/RenderProfiler3D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/SamplingProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/TracyGpuProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/profiler/TracyProfiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/FullScreenRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/MeshRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/RenderWarp.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/RenderWarpStack.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/Renderer2D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/Renderer3D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/BlurSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/Deferred2DSubmit.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/DeferredActionSubmit.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/DeferredOrderedSubmit.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/DeferredTexturedSubmit.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/DrawBatch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/FrameBlurCacheEntry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/HudDeferredGuiElement.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/ItemBatch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/ItemBatchRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/ItemDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/ItemResolveKey.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/OrderedUiBatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/SharedBlurProfile.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/TextBatch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiBatchPlan.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiBatchStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiBatchType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiBatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiBlurResources.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiDeferredScheduler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiDirectTexturedRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiEffectGraph.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiItemSubmission.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiPassCompiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiPassExecutor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiRenderDispatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/UiRendererSubsystem.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiBoxPathBuilder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiBoxShape.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiChamferRadii.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiCornerKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiCornerMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiCornerRadii.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiCornerSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiEdgeKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiEdgeSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiEffectKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiEffectSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiMeshGeometry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiPaint.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiPaintKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiPaints.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiPathCap.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiPathJoin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiRect.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiShape.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiShapeKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiShapeShaderParams.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiShapes.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/draw/UiStroke.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/action/UiAction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/action/UiActionContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/action/UiActionRef.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/action/UiActionRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/animation/UiAnimationSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/animation/UiAnimationState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/animation/UiEasing.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/asset/UiAssetKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/asset/UiAssetProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/asset/UiAssetRef.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/asset/UiAssetRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/asset/UiAssetResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiBounds.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiComponentFactory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiComponentRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiDocument.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiLifecycle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiNode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiNodeSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiNodeType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiProps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiRuntimeException.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/core/UiState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/debug/UiPerfCounters.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/debug/UiRuntimeDiagnostics.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/debug/UiTreeDumper.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/error/UiErrorPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiCursorManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiFocusManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiHitResult.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiHitTester.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiInputDispatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiInputState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiKeyboardEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiPointerEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/input/UiScrollEvent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/layout/UiLayoutEngine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/reconcile/UiDiffStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/reconcile/UiKeyPath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/reconcile/UiNodeMatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/reconcile/UiReconcileContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/reconcile/UiReconciler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiBlendSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiClipStack.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiImageRendererBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiItemRendererBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiPrimitiveRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiProjectionMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiTextEffectRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/render/UiTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/CachedUiScriptRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/JavetNativeResourceLoader.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/JavetUiScriptEngine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiBoundsPatchSet.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptColor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptComponent.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptEngine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptEngineProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModule.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModuleHandle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModuleId.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModuleLoader.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModuleRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptModuleTransform.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptObjectConverter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptPatchSet.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptProps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptRenderContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptRenderResult.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptRuntimeError.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptRuntimeSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiScriptSourceKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/UiTypeScriptStripper.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiAlign.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiColor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiJustify.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiOverflow.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiResolvedStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiStyleCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiStyleParser.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiStyleResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiStyleToken.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiTheme.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/style/UiThemeRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/CombatantRhi.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/FullscreenDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/GpuMeshHandle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/MeshOwnership.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/RhiDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/RhiSamplerBinding.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/RhiStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/RhiStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/RhiUniformBinding.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/SodiumGlBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/gl/GlBackendAccess.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/gl/clip/GlStencilFramebufferSupport.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/gl/clip/GlStencilShapeClipBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/gl/state/SodiumGlPipelineStateBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/CombatantVulkanBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/clip/VulkanShapeClipBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/clip/VulkanShapeClipBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/clip/VulkanStencilAttachmentManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/msaa/VulkanMsaaControl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/msaa/VulkanMsaaResolveBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/state/VulkanPipelineStateBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/util/VulkanRenderStateBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/util/VulkanRuntimeGuards.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/blit/Blaze3dTextureBlitter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/blit/TextureBlitter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/clip/ShapeClipBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/clip/ShapeClipRenderPassContract.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/fullscreen/Blaze3dFullscreenBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/fullscreen/FullscreenBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/msaa/MsaaControl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/msaa/SodiumGlMsaaControl.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/BlendPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/CullPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/DepthPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/FogPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/PipelineDomain.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/PipelineKey.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/PipelineMetadata.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/RenderPipelineRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/RenderPipelineSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/UniformLayoutSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/pipeline/VertexLayoutSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/FrameResourceRetirementQueue.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/FramebufferDescriptor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/FramebufferPool.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/GlyphAtlasManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/GlyphAtlasPage.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/RenderResourceManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/RenderResourceStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/ResourceLeakTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/SamplerCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/resource/TexturePool.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/scissor/GlobalScissorState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/state/PipelineStateBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/uniform/CombatantUniformAllocator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/uniform/FrameResource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/uniform/LegacyUniformFrameResource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/uniform/UniformAllocatorStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/uniform/UniformAllocatorStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/upload/Blaze3dDynamicMeshBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/upload/Blaze3dFrameFence.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/upload/Blaze3dMeshAllocation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/upload/Blaze3dMeshArena.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/rhi/upload/DynamicMeshBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/shader/CombatantShaderSources.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgColorMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgInlineText.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgMeshBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgMsdfRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgPathParser.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgRenderOptions.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/svg/SvgStyleUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/BuiltinFontFace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/CustomTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/EmptyGlyphFont.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/Font.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/FontDebugStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/FontFace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/FontFamily.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/FontInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/FontUtils.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/Fonts.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/GlyphFont.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/MsdfFont.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/SystemFontFace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextBackendPreference.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextClipSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextCommandBuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextCommandStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextCommandStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextEffectSpec.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextGlyphFallback.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextRenderSystem.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/TextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/VanillaTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/WorldTextRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/BitmapAtlasTextBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/GlyphAtlasManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/GlyphAtlasPage.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/MsdfTextBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextBackendRouter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextPlacementMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextPlacementResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextPlacementTransform.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/TextRenderDomain.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/text/backend/VanillaSodiumTextBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshBuilder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/MeshData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/DepthOfFieldUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/HandGlassUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/HandMetallicUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/HandSmokeUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/HeatUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/MenuBackgroundUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/MeshUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/MotionBlurUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/MsdfTextUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/PostFXUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/PostProcessUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/ShaderEspBlurUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/ShaderEspGradientUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/ShaderEspSmokeUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/SkySunUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/SkyboxShaderUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/TextureTintUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/UIBatchUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/UIBlurUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/storage/FixedUniformStorage.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/vertex/CombatantVertexFormatElements.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/vertex/CombatantVertexFormats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/visuals/CombatantVisualFrame.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/visuals/CombatantVisualPass.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/visuals/CombatantVisualPhase.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/visuals/CombatantVisuals.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldBatcher.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldCommandBuffer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldDrawCommand.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldPassCompiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldRenderStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldRenderStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/world/WorldWideLineMeshCompiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/ClipFunction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/EffectIconRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/GuiSpriteBatch.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/HeadAnchor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/MatteHudStyle.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/Particle3D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/ParticleTextureMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/PlayerHeadRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/ScissorFunction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/ScreenProjection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/ScreenSpaceOverlay2D.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/SodiumMaterialFlags.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/SodiumSurfaceFlagContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/SystemCursor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/TickDelta.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/helpers/TrailPoint.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/CombatantIrisUniforms.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisCombatantFrameHooks.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisCompatibilityFeature.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisCompatibilityGuards.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisCompatibilityProfile.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisCompatibilityProfiles.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisFinalizedSceneRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisHandMaskState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisRuntimeBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisRuntimeSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisSceneDepth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/IrisSecondHandScene.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/patch/ShaderPatchCompiler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/patch/ShaderPatchCompilerCli.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/iris/patch/ShaderPatchEngine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sky/CustomSkyboxRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumFrameContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumRenderBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumSectionVisibilityProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumShaderWorkarounds.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumTerrainInterop.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumTerrainInteropStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/SodiumVisibilityStatsSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/terrain/CombatantChunkMeshAttributes.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/terrain/CombatantChunkMeshFormats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/terrain/CombatantChunkVertexExtension.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/terrain/CombatantModelVertexType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/render/sodium/terrain/CombatantTerrainVertex.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/ClientRuntime.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/ClientRuntimeState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeCleanupReport.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeDiagnostics.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeGate.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeResumeParticipant.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeShutdownContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/RuntimeShutdownParticipant.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/JarReplacementCoordinator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/LoaderBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/PanicOnlyJarReplacementCoordinator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/RuntimeGateSourcePolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/RuntimeSourcePolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/runtime/loader/StandardFabricLoaderBridge.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/FastFps.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/LegacyHudNotifier.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/NarratorBlocker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/OmniItemUtils.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/AimPointUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/RequestHandler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/RestrictedSingleUseAction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/RotationManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/RotationTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/RotationUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/data/Rotation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/data/RotationDelta.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/data/RotationWithVector.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/MovementCorrection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/FailRotationProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/LazyAimProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/MovingRotationProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/RotationProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/ShakeAimProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/SnapAssistAimProcessor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/AngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/FactorAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/NoneAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/AccelerationAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/InterpolationAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/LinearAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/SigmoidAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/SmartAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/features/processors/anglesmooth/impl/SpookyAngleSmooth.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/point/PointInsideBox.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/point/PointTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/preference/LeastDifferencePreference.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/preference/RotationPreference.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/aiming/raytrace/RotationRaytrace.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockDeobfuscationScanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockEspSodiumCandidateCollector.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockObservationHub.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockOutlineRenderer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockOutlineShapeMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockScanContext.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockScanMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/BlockSearchUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/RenderSectionBlockScanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/WorldSectionBlockScanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/bed/BedBlockUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/bed/BedwarsTeamColorUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/bed/SelfBedMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/bed/SelfBedTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/placer/BlockPlacer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldBlockItemSelection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldFacePositionFactory.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldInteractionTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldInteractionTargetFinder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldLine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldMovementPlanner.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldMovementPrediction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldPlacementGate.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldPlacementTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/block/scaffold/ScaffoldTargetFinder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/click/AttackPressing.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/click/ClickHumanizer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/click/ClickScheduler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AntiBotTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AttackUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/CombatRotationModeUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/CombatStrikeController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/LethalStrikeCalculator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/ProtocolAttackExecutor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/RubberHandUseUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/SprintController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/VulcanReachController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/ClientProtocolVersion.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/CombatProtocolHeuristicSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/CombatProtocolHeuristics.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/CombatProtocolHeuristicsConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/ProtocolUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/vfp/VfpCompatibility.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/protocol/vfp/VfpCompatibility1_8.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/EagleUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/FreecamEntity.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/simulation/BoatSimulationCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/simulation/MountedEntityPrediction.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/simulation/PositionExtrapolation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/entity/simulation/PositionPredictor.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/input/KeyManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/input/KeyUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/EnchantMeta.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/EnchantRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/EnchantUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/FoodUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/IllegalItemUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/ItemRarityColorProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/RarityColorConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/RarityColorUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/item/TopEnchantUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/logging/DebugLog.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/logging/DebugMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/logging/ServerDumpUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/map/MapPlayersCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/IMediaSession.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/MediaInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/MediaPlayerInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/MediaSessionService.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/RepeatMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/DummyMediaPlayerInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/linux/LinuxMediaPlayerInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/linux/LinuxMediaSession.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/linux/dbus/Player.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/win/WindowsMediaPlayerInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/media/impl/win/WindowsMediaSession.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/network/BacktrackController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/network/BlinkManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/network/FakeLagController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/network/PacketSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/network/TransferOrigin.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/ElytraRecastUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/EnvironmentMovementController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/InteractionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/MovementUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/NetworkStatsUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/PlayerHealthResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/PlayerSkinResolver.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/PlayerSpeedUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/ResetAttackCooldown.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/ServerFlyUtils.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/effect/StatusEffectAccess.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/effect/StatusEffectHeuristics.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/effect/StatusEffectInference.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/effect/StatusEffectTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/effect/StatusEffectView.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/FireworkUseController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventoryActionKind.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySearchScope.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySwap.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySwapMode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySwapPolicy.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySwapRequest.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/InventorySwapVisibility.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/inventory/SlotResult.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/LocalPlayerSimulation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/PlayerMovementSimulation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/PlayerSimulationCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/RemotePlayerObservation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/RemotePlayerSimulation.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/player/simulation/SimulatedInput.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/CydhranianProjectileAngleCalculator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/PointFinding.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/PolynomialProjectileAngleCalculator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/ProjectileAngleCalculator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/ProjectileMath.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/ProjectilePredictionUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/ProjectileTarget.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/ProjectileTargetPointFinder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/SimulatedArrow.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/SituationalProjectileAngleCalculator.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/TrajectoryData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/TrajectoryInfo.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/projectile/TrajectoryType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyBackend.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyConfig.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyEntry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyHandshakeHandler.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyNettyInstaller.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/proxy/ProxyType.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/CooldownRegistry.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/ItemCooldownRuleProvider.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/ItemCooldownSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/ItemUseCooldowns.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpChatParser.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpOverlayParser.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTabParser.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTagSource.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTargetState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTargetTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTextPatternUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/PvpTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/client/CooldownManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/client/CooldownsState.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/client/PendingUseTracker.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/opponents/OpponentCooldownManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/opponents/TotemPopCounter.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/pvp/opponents/TotemPopSnapshot.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/raycast/RaycastUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/resources/RenderResourceReadiness.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/resources/ResourceReloadHooks.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/screen/ClientScreen.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/screen/ScreenCatalog.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/MicrosoftSessionResult.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/MinecraftGameConfigHolder.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/SessionChanger.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/SessionLoginData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/DeviceAuthPendingException.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MicrosoftAuthException.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MicrosoftAuthService.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MicrosoftDeviceCode.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MicrosoftJson.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MicrosoftTokens.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/MinecraftProfile.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/session/microsoft/XboxToken.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/target/TargetManager.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/target/TargetingUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/ChatNameUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/ClipboardUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/LegacyTextUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/TextJsonUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/TextRenderUtil.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/text/TextSelection.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/time/Timer.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/time/TimerController.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/ClickGuiSounds.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/CustomSoundEngine.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/SourcePool.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavBufferCache.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavData.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavDebugStats.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavLoader.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavSound.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/wav/WavSoundHelper.java create mode 100644 combatant-client-26.2/src/main/java/dev/loki/lovisual/util/world/ExplosionDamageUtil.java create mode 100644 combatant-client-26.2/src/main/native/mediainfo/CMakeLists.txt create mode 100644 combatant-client-26.2/src/main/native/mediainfo/main.cpp create mode 100644 combatant-client-26.2/src/main/native/mediainfo/main.h create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/comfortaa.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/icons.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/icons2.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iconsnur.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/inter_bold.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/inter_medium.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/inter_regular.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-bold.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-bolditalic.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-light.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-lightitalic.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-medium.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/iosevka-mediumitalic.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/mediaplayer.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/monsterrat.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/comfortaa.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/comfortaa.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/guiicons.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/guiicons.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_bold.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_bold.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_medium.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_medium.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_regular.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/inter_regular.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-bold.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-bold.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-bolditalic.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-bolditalic.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-medium.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-medium.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-mediumitalic.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/iosevka-mediumitalic.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/mainmenuicons.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/mainmenuicons.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/monsterrat.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/monsterrat.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_bold.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_bold.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_light.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_light.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_medium.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_medium.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_regular.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/onest_regular.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/profont.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/profont.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/richicons.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/msdf/richicons.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/onest_bold.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/onest_light.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/onest_medium.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/onest_regular.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/profont.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/vanilla_symbols.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/font/weather_icons.ttf create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/arc_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/chamfered_rect_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/chamfered_rect_stroke_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/circle_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/core/entity_dither.fsh create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/core/entity_dither.vsh create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/damage_tint.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/damage_tint.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/depth_of_field.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/gui_texture_lookup.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_glass.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_metallic.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_smoke.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/heat_fx.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/common.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/composite1.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/composite4.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/deferred1.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/fog.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/lighting.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/manifest.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/complementary_reimagined/r5/water.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/index.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/photon/v1_3b/c0_vl.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/photon/v1_3b/c1_blend_layers.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/photon/v1_3b/lighting.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/photon/v1_3b/manifest.json create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/iris-patches/photon/v1_3b/motion_blur.patch create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/menu_background_aurora.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/menu_background_waves.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/metallic.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/motion_blur.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/orbiz_ring_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/portal_rift.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color_fog.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color_fog.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color_rect_params.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color_rect_params2.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_color_skybox_shader.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_local_color_rect_params.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_local_color_rect_params2.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_local_color_rect_params5.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_local_color_rect_params5_generic.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_params2.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_rect_params.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_rect_params2.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_sky_fog.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_sky_fog.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_sky_fog_additive.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_color_tint.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_local_color_rect_params.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/pos_tex_local_color_rect_params2.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/post_fx.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/radial_glow_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/reimagined_skybox.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_corners_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_glow_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_shadow_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_soft_shadow_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_stroke_angular_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_stroke_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_stroke_corners_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_tex_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/rounded_rect_tex_mask_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/shader_esp_gradient.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/shader_esp_shadow.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/shader_esp_smoke.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_sun.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sleep_overlay.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sodium/block_layer_opaque.fsh create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sodium/block_layer_opaque.vsh create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/svg_msdf.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/text.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/text.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/text_liquid_glass.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/text_liquid_glass_msdf.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/text_msdf.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_blur.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_blur_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_blur_batch_corners.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_liquid_glass_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_rounded_fill_smoke_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/ui_shape_batch.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wide_line.vert create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/shaders/world_decal_sdf.frag create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/disable/disable1.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/disable/disable2.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/disable/disable3.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/enable/enable1.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/enable/enable2.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/enable/enable3.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guibinding.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guibindingnull.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guibindingstart.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guibindreset.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guichangemode.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guimodulehover.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guino.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guiscroll.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guislidermove.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/guiyes.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/moduleclose.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/moduleonclose.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/moduleonopen.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/gui/moduleopen.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/hitsounds/bell.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/hitsounds/bonk.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/hitsounds/bubble.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/hitsounds/crime.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/hitsounds/critical.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/misc/friendadd.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/misc/friendremove.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/misc/orthodox.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/sounds/misc/warning.wav create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/.gitkeep create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/accessibility.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/arrow-up-from-line.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/brain-cog.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/brush.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/check.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/clipboard.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/columns-3-cog.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/cross.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/diff.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/download.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/ellipsis.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/external-link.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-archive.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-check.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-clock.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-closed.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-cog.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-down.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-open.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-output.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-pen.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-plus.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-sync.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-tree.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder-x.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/folder.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/heart-plus.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/heart-pulse.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/minus.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/package.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/paintbrush.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/palette.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/repeat-1.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/repeat-off.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/repeat.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/save.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/shield-user.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/swords.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/toggle-left.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/toggle-right.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/toggle-thumb.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/toggle-track.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/trash-2.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/tree-deciduous.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/tree-palm.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/tree-pine.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/trees.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user-check.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user-cog.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user-minus.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user-pen.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user-plus.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/user.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f30a.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f356.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f3a3.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f3f9.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f514.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f525.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f531.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u1f9ea.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/vanilla/u274c.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/svg/x.svg create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/bloom.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/glow_particle.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/arrow.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/arrow2.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/arrow3.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/arrow4.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/capture.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/coords.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/fps.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/memory.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/ping.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/server.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/speedometer.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/hud/elements/tps.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/bubble.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/cross.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/cube.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/default_circle.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/feather.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/funnel/distortion.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/funnel/funnel_eye.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/glow.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/jump_circle.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/mcross.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/moon.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p1.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p10.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p11.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p12.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p2.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p3.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p4.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p5.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p7.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p8.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/p9.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/spark.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/star.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/particles/triangle.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/timers/arrow.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/timers/pearl.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/timers/potion.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/timers/tnt.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/textures/timers/trident.png create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/actions.d.ts create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/assets.d.ts create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/clickgui/settings_panel.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/clickgui/theme_preview.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/compact_stat.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/inventory_panel.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/itemizer.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/list_panel.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/panel_base.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/draggable/triangulator.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/static/custom_health_bar.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/static/dynamic_island.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/hud/static/tooltip_panel.js create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/runtime.d.ts create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/theme.d.ts create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/ui.d.ts create mode 100644 combatant-client-26.2/src/main/resources/assets/lovisual/ui/api/ui.js create mode 100644 combatant-client-26.2/src/main/resources/fabric.mod.json create mode 100644 combatant-client-26.2/src/main/resources/javet/natives/README.txt create mode 100644 combatant-client-26.2/src/main/resources/lovisual.mixins.json create mode 100644 combatant-client-26.2/src/main/resources/mediaplayerinfo/natives/win/MediaPlayerInfo.dll create mode 100644 combatant-client-26.2/src/svgMsdfTool/java/dev/loki/lovisual/render/engine/svg/SvgMsdfPreprocessor.java create mode 100644 combatant-client-26.2/src/test/java/dev/loki/lovisual/features/command/CommandDiscoveryTest.java create mode 100644 combatant-client-26.2/src/test/java/dev/loki/lovisual/features/command/impl/XaeroWaypointCommandTest.java create mode 100644 combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/player/NoInteractTest.java create mode 100644 combatant-client-26.2/src/test/java/dev/loki/lovisual/render/engine/renderer/ui/runtime/script/JavetUiScriptEngineTest.java create mode 100644 combatant-client-26.2/src/test/java/dev/loki/lovisual/util/block/scaffold/ScaffoldPlacementGateTest.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/CommandEncoderDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/RenderPassWrapperDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanBackendMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanCheckpointDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanCommandEncoderDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanDebugMessageMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanDeviceDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanQueueSubmissionMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanRenderPassDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/mixins/VulkanRenderPipelineDiagnosticsMixin.java create mode 100644 combatant-client-26.2/src/vulkanDebug/java/dev/loki/lovisual/render/engine/rhi/backend/vulkan/debug/VulkanCrashDiagnostics.java create mode 100644 combatant-client-26.2/src/vulkanDebug/resources/lovisual-vulkan-debug.mixins.json create mode 100644 combatant-client-26.2/tools/msdf/GenerateSvgMsdf.ps1 create mode 100644 combatant-client-26.2/tools/msdf/msdf-atlas-gen.exe create mode 100644 combatant-client-26.2/tools/msdf/msdf-atlas-gen/msdf-atlas-gen.exe create mode 100644 combatant-client-26.2/tools/msdf/msdfgen-1.13-win64/msdfgen/example.bat create mode 100644 combatant-client-26.2/tools/msdf/msdfgen-1.13-win64/msdfgen/msdfgen.exe create mode 100644 combatant-client-26.2/tools/msdf/msdfgen.exe diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3624a1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Gradle +.gradle/ +build/ +*/build/ +*/gradle/wrapper/gradle-wrapper.jar +gradlew +gradlew.bat + +# IDE +.idea/ +*.iml +.vscode/ + +# Rust +*/target/ + +# Go +*/vendor/ + +# OS +.DS_Store +Thumbs.db + +# Minecraft +*/run/ + +# Config +config/ + +# Logs +*.log + +# Reference files (too large) +ref/ + +# Binaries +*.jar +*.rar +*.zip +backend/server diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ea3190c --- /dev/null +++ b/TODO.md @@ -0,0 +1,248 @@ +# LoVisual — План разработки с нуля + +## Принципы (в отличие от прошлого кала) +- **Никаких пустышек** — файл без реализации не создаётся +- **Никаких this-escape** — конструктор не публикует this +- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле +- **Никаких мёртвых миксинов** — миксин только если он реально нужен +- **Каждый этап компилируется** — перед переходом к следующему +- **Никаких 140 папок** — плоская структура, группировка только по смыслу + +--- + +## База: что осталось +- `ref/` — ~415 референс-модулей из 8 клиентов (не трогать) +- `.omo/` — конфиги opencode +- `.git/`, `.gitignore`, `.gitattributes` +- `SYSTEM.md` — описание архитектуры (актуализировать по ходу) + +--- + +## Фаза 0: Project Setup +- [ ] Создать `mod/` (NeoForge 1.21.1, Gradle, Java 21) + - build.gradle.kts (userdev, checkstyle) + - settings.gradle.kts + - META-INF/neoforge.mods.toml + - access_transformer.cfg (только нужные записи) + - mixins.json (только файлы, которые реально есть) + - LoVisual.java — entry point + - LoVisualClient.java — client init +- [ ] Создать `backend/` (Go 1.24) + - go.mod + - cmd/server/main.go + - internal/config/config.go (!! существует до main.go) +- [ ] Создать `launcher/` (Rust) + - Cargo.toml + - src/main.rs + +### Проверка: `./gradlew build`, `go build ./cmd/server`, `cargo build` + +--- + +## Фаза 1: Core Systems (мод) +- [ ] EventBus — свой, нестатический, с поддержкой cancellation + - Event.java (abstract), EventBus.java + - EventHandler аннотация + - post() прекращает итерацию при cancel +- [ ] Module base — без `Minecraft.getInstance()` в статике + - Module.java: lazy getMc(), рефлексия для settings, toggle/enable/disable + - ModuleInfo аннотация + - ModuleCategory enum (COMBAT, MOVEMENT, RENDER, PLAYER, MISC) +- [ ] Setting system — 6 типов: + - Setting, BooleanSetting, SliderSetting, ModeSetting, ColorSetting, BindSetting, MultiSelectSetting +- [ ] ModuleManager — регистрация, получение по имени/категории +- [ ] Command system — toggle, bind, help, config +- [ ] ConfigManager — .loconf, профили, автосейв (только после полной инициализации) +- [ ] Theme / ThemeManager — без garbage (Color кэш или прямые int) + +### Проверка: мод запускается, не падает, `/lovisual help` работает + +--- + +## Фаза 2: Mixins (только нужные) +- [ ] MinecraftClientMixin — runTick() → TickEvent +- [ ] InGameHudMixin — render → Render2DEvent +- [ ] GameRendererMixin — renderLevel → Render3DEvent +- [ ] ClientConnectionMixin — send/receive → PacketEvent +- [ ] KeyboardHandlerMixin — keyPress → KeyPressEvent +- [ ] MouseHandlerMixin — click → MouseClickEvent +- [ ] ClientPlayerEntityMixin — travel → MoveEvent (если нужен) + +### НИКАКИХ пустых миксинов. Никаких `cancellable=true` без cancel. + +### Проверка: все события постится, можно залогировать из любого модуля + +--- + +## Фаза 3: Первый рабочий модуль +- [ ] Sprint (MOVEMENT) — полный цикл: + - Вкл/выкл через `/lovisual toggle Sprint` + - onEnable → подписка, onDisable → отписка + - TickEvent → зажим CTRL + - Сохраняется в конфиг + - Клавиша привязывается через `/lovisual bind Sprint 29` + +### Проверка: модуль работает, конфиг сохраняется/грузится + +--- + +## Фаза 4: Modules — по одному, каждый рабочий + +### Combat (4) +- [ ] AutoClicker — CPS на клик, не юзать System.currentTimeMillis() +- [ ] Trajectories — Render3DEvent, предсказание траектории +- [ ] Criticals — фолл-криты +- [ ] Velocity — анти-отбрасывание + +### Movement (3) +- [ ] Sprint ↑ (готов) +- [ ] Speed — страф/вода/эйр +- [ ] AutoWalk — зажим W + +### Render (12) +- [ ] FullBright — гамма +- [ ] NoHurtCam — отмена тряски +- [ ] NoWeather — отмена погоды +- [ ] NoBobbing — отмена качания +- [ ] Zoom — FOV smooth (без глитча при disable) +- [ ] Crosshair — кастомный +- [ ] Nametags — ники над игроками +- [ ] HealthTags — хп под никами +- [ ] HitParticles — частицы при ударе +- [ ] Chams — подсветка (миксин читает настройки модуля) +- [ ] Animations — свайпы/рука +- [ ] CustomGlint — кастомный глинт + +### Player (8) +- [ ] AutoTool — автосмена инструмента +- [ ] ExpThrower — авто-бросок опыта +- [ ] InvManager — сортировка/мусор +- [ ] MiddleClickFriend — добавить в друзья +- [ ] InventorySort — сортировка по клику +- [ ] Refill — авто-заполнение стаков +- [ ] NoInteract — блокировка кликов по сущностям +- [ ] Friends — список друзей + +### Misc (6) +- [ ] AntiAFK — анти-афк +- [ ] AutoRespawn — авто-респавн +- [ ] AutoReconnect — авто-переподключение +- [ ] IRC — WebSocket чат +- [ ] Spammer — авто-спам в чат +- [ ] NameProtect — защита ника + +### Extra (из ref/: только если реально нужны) +- [ ] KillAura +- [ ] ESP +- [ ] Scaffold +- [ ] Freecam +- [ ] Blink +- [ ] ElytraFlight +- [ ] AntiBot +- [ ] HitBox +- [ ] Tracers +- [ ] ItemPhysics +- [ ] ChestStealer +- [ ] AutoFish +- [ ] AutoEat + +### Проверка после каждого: модуль компилируется, включается/выключается, не крашит + +--- + +## Фаза 5: GUI + +### ClickGUI +- [ ] ClickGuiScreen — Screen +- [ ] CategoryPanel — чипсы категорий + поиск +- [ ] ModuleGrid — сетка модулей (ЛКМ toggle, ПКМ настройки, СКМ бинд) +- [ ] SettingsPanel — рендер настроек +- [ ] ThemePanel — слайдеры цветов + +### HUD (10 элементов) +- [ ] Watermark +- [ ] ModuleList +- [ ] TargetHUD +- [ ] ArmorHUD +- [ ] PotionsHUD +- [ ] Keystrokes +- [ ] Scoreboard +- [ ] Coords +- [ ] ComboCounter +- [ ] Notifications + +### MainMenu +- [ ] LoVisualMainMenu — кастомный экран + +### Проверка: GUI рендерится, кликабельно, модули togglятся + +--- + +## Фаза 6: Backend (Go) + +### Config (существует до main.go) +- [ ] internal/config/config.go — Port, DB.Path, JWT.Secret, CORS origins + +### DB +- [ ] internal/repository/db.go — SQLite, миграции +- [ ] internal/repository/users.go +- [ ] internal/repository/configs.go +- [ ] internal/repository/profiles.go + +### Auth +- [ ] internal/service/auth.go — bcrypt, JWT (не SHA256) +- [ ] internal/handler/auth.go — register/login/logout +- [ ] internal/middleware/auth.go — Bearer token (не panic, возвращает 401) + +### Profile +- [ ] internal/service/profile.go +- [ ] internal/handler/profile.go — get/update/playtime/search/friends +- [ ] JSON decode errors НЕ игнорировать + +### Configs API +- [ ] internal/handler/config.go — upload/download (auth check, user_id из токена, не из тела) +- [ ] 16-byte share keys (минимум) + +### IRC +- [ ] internal/irc/hub.go — WebSocket хаб (gorilla/websocket, не deprecated x/net) + +### Middleware +- [ ] CORS — настраиваемые origins +- [ ] RateLimit — с cleanup горутиной (без утечки) +- [ ] Log — запросы + +### Проверка: `go build ./cmd/server` проходит, сервер стартует, эндпоинты отвечают + +--- + +## Фаза 7: Launcher (Rust) + +### API клиент +- [ ] api.rs — reqwest, таймауты, десериализация с rename_all +- [ ] Версия читается из Cargo.toml (не хардкод) + +### Экраны +- [ ] login.rs — регистрация/логин +- [ ] dashboard.rs — профиль, playtime, version check +- [ ] settings.rs — кастомная команда (ЧИТАЕТСЯ на Play, не stale) + +### Launch +- [ ] launch.rs — запуск MC (команда из настроек, защита от пустой строки) +- [ ] save_session — только при логине, при logout удалять + +### Проверка: `cargo run` — лаунчер открывается, логин/регистрация работают + +--- + +## Фаза 8: Polish + +- [ ] Sound system (49 звуков из ref/) +- [ ] Animation system (SpringAnimator) +- [ ] Style system (6 стилей для HUD) +- [ ] Cloud config sharing (если бэкенд готов) +- [ ] Auto-updater +- [ ] Loading screen +- [ ] AltManager + +--- + diff --git a/combatant-client-26.2/.gitignore b/combatant-client-26.2/.gitignore new file mode 100644 index 0000000..f805edc --- /dev/null +++ b/combatant-client-26.2/.gitignore @@ -0,0 +1,45 @@ +# === Gradle === +.gradle/ +build/ +out/ + +# === IntelliJ IDEA / Eclipse / VSCode === +.idea/* +!.idea/misc.xml +*.iml +.classpath +.project +.settings/ +.vscode/ + +# === Logs / Cache === +logs/ +run/ +cache/ +*.log + +# === OS-specific === +Thumbs.db +.DS_Store + +# === Exported / Generated files === +exports/ +export/ +export_cache/ +*.jar +!gradle/wrapper/gradle-wrapper.jar +*.zip +/local-maven/ +/to-do-list +/.tmp/ +/.tmp_mc/ +/.tmp_sodium/ +/.tmp_iris/ +/.tmp_photon/ +/.tmp_complementary/ +/!new_temp_assets/ +/src/main/resources/javet/natives/libjavet-v8-linux-x86_64.v.5.0.9.so +/src/main/resources/javet/natives/libjavet-v8-macos-x86_64.v.5.0.9.dylib +/src/main/resources/javet/natives/libjavet-v8-windows-x86_64.v.5.0.9.dll +/RELEASE_CHECKLIST.md +/.claude/ diff --git a/combatant-client-26.2/LICENSE b/combatant-client-26.2/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/combatant-client-26.2/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/combatant-client-26.2/README.md b/combatant-client-26.2/README.md new file mode 100644 index 0000000..55bb03e --- /dev/null +++ b/combatant-client-26.2/README.md @@ -0,0 +1,195 @@ +
+ +# LoVisual + +Minecraft utility client for PvP and automation. +Fork of [Combatant Client], built on ideas from LiquidBounce, Meteor, and ThunderHack. + +![Java](https://img.shields.io/badge/Java-25+-orange?style=flat-square&logo=openjdk&logoColor=white) +![Fabric](https://img.shields.io/badge/Fabric-1.21.5-dimgrey?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAFHGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczp3ZXQvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA2LjAtYzAwMCA3OS4xNjUyNzcsIDIwMjAvMTAvMTUtMTc6NDg6MzIgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiLz4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4AAAAAAAAAAAAAACnyRQAABO9JREFUSA2FVutvFFUUP+fOrB1K29JQWCAq0gei1PZBNk1KqWA0loaE+IVEfTDxP/iB+JHEL5r4hchfICFEYoiJD0yMCRqDNjWKmlhKS1sKxdLd7Ux3d86PM3f37uws7REmOTP3nnPu79x77jnnXohzrzj2dxN+dmME5OASIF8B8hUgR4HcDYATJCLHLly5A8gFQC4BcgLkOSAnQZ4M2TwBwSpx1MFSxFEF5Kx3JXKs4vvdDZBrAXINkG6Q00D2gDwDZBcifE2n62BcJIUAnwQYA5gEGAf4BOB9gI8AXgd4AuBxRERzWq7Vh/x69eKz5cpSq9ODiLgLEVdPALVqrx4B+ArgMmJ1FM92G5D77p94Ynx8fLBarQ4A/BcY1QE5CbAMsKLi/wjAz5ubm7+vra29sLW19ReA0gCqN0H2LyL2b9269RYiDiPiU8SFrgK0AtxsffO7X6+8sLy8/Eq1Wn0a5McAvU4H+RAg70HEeYiYQcQmREwjYh0AE4iYCMK1CHCv/eDA3qXfr/QNDw/fQ8TviKD7hYSU4mAQ4PdKpfLRhx9+eEsRtYi4JYQvIGLiEJEERJxAxH7LdgRgh7k2NtZ/+/Z0f7FY7EHEHxHxBRHADgAC0A/QaN+7t3r69OkjQ0NDMwBjAOqUjkQER4CMjoyM3N27dy8WCoVXhVYBnm3XWAXo3b1798Fms3k7l8s9CsDLV0XUXYBcR0Sy6enpS8YYSzQh4rVW63hBdqPRmAHAJEKIEFIADYhYo2lttVoXms3m/wB4+OF1Tjvn3PPnz8+k02kHEe8i4hn7WwC+DhQKuVxuUhGjKMIDRscTEHFPKTWZz+df9E1QSsmYu7i4eBQA/78uUPp7uZFIpZZqtYFsNot7e3v4+PFj3N3dxUqlgvl8Ppb4PwEixwAOAcDm5uY7S0tLr8XaKl6eTqexr68Pr127hoODgzg2Noa5XA7z+Tzm83kcGhrCa9eu4cDAgC8nGcbYOETEWxK01kLce2ZmZtznuxBXFvx8Po+lUgk/+OAD/Oqrr/Dzzz/Hjz/+GD/99FM8f/48njlzBpeWlnBpaQkzmQwCQNQK3KvWs2fPhr3FS8zn87i+vo4AKb/YOzs7+MUXX+Cff/6J8/PzOD09jW+88Qa+/fbb+MYbb+Dk5CTAexEMgMog0lAKQkH99ddfcWpqComkH374QcaDg4N4+vRpHBgYwGQyiblcDsfGxjCRSMi7D14CwEWA3RiP++7fvy/jnZ0d3N7elnGz2ZRxKpXCdDqNQgg8cOAA7NmzB5PJJGoNMQKwIT3wf29paUlgb2xsoLV2n3K5jLdv38YffvgBZ2Zm8Pvvv8dffvkFV1dXcWFhAQH4EiDHAHStVsNerycrT09P4/LysnP20qVLeOHChW4GJKVUKpUyxlAmk0GlFGqt/x8A7mGz2fRBEN68efOk/KPRaODc3Jxw5eeffw4ATr65uSnjnZ0dtNZiKpVCY0w3rTGNRgMB+BTAdACg1mKMYbVaLZbL5TMnTpyQ0N5++20sFAqyv7e3h0opyePR0VEsFAqYy+U4JiYf2d3de10p9bS8DgBypVKpVwaLRn0AOwD35H2ot3J+fp7T6TQXi0WenJzkr7/+mt99913+5JNPRBD/+9//5EuXLvGZM2fkTK1W+1Qp9VIQBHcAeA5gRSm1c/DgwesCbk4p9bTkrRDCGIMLCwsYhiGu/G+IcByr1Wo/m80WlVI7Us+fOw7HZ/KlAqSFEFgsFh9Lp9M7ADWAKUQ8Ii8Rx4mnbAaYU0pd10r/Z4zZFSLWnHP3bNu+KKR/EoJ3RA==) +![License](https://img.shields.io/badge/license-GPLv3-blue?style=flat-square&logo=gnu&logoColor=white) + +[English](#english) | [Русский](#русский) + +
+ +--- + +## English + +### Overview + +LoVisual is an open-source utility and PvP client for Minecraft. It provides configurable combat, movement, visual, automation, HUD, and UI features with a custom ClickGUI, theme system, and addon API. + +This repository is a **fork** of [Combatant Client], which drew inspiration from [LiquidBounce], [Meteor Client], and [ThunderHack Recode]. The goal is to continue development, add new features, and maintain compatibility with the latest Minecraft versions. + +### Quick Start + +1. Install Fabric Loader 0.19.3+ for MC 26.2+ +2. Download the LoVisual jar +3. Place it in your `mods/` folder along with Fabric API and Sodium +4. Open the ClickGUI with **`RShift`** (configurable) + +### Features + +| Feature | Description | +|---------|-------------| +| Combat | KillAura, AutoCrystal, AutoAnchor, AutoBed, AutoBow, Criticals, Reach, Velocity, Backtrack, and more | +| Movement | Speed, Flight, ElytraFly, ElytraHelper, Sprint, Timer, Strafe, TargetStrafe, Scaffold, Parkour | +| Visuals | ESP, NameTags, Chams, Tracers, Crosshair, Zoom, Freecam, FullBright, MotionBlur, NoRender | +| Player | AutoTool, AutoEat, AutoArmor, AutoTotem, Scaffold, ClickPearl, FakeLag, XCarry | +| Misc | BetterMinecraft, FakePlayer, HitSounds, Panic, NoSound | +| HUD | Draggable elements, TargetHUD, ModuleList, Watermark, Armor, Potions, Keystrokes, Coords, Notifications, Scoreboard | +| ClickGUI | Full-featured GUI with module browser, settings editor, theme editor, config profiles | +| Config profiles | `.lvisual` profiles for modules, HUD, and themes — shareable between users | +| Addon API | Versioned addon API for extending the client | +| Custom renderer | Custom UI rendering with MSDF fonts, SVG icons, and shader support | +| Iris/Sodium/Xaero | Optional compatibility with popular mods | + +### Compatibility + +| Mod | Status | +|-----|--------| +| Sodium | ✅ Required | +| Iris | ✅ Optional — shaderpack support | +| ImmediatelyFast | ✅ Optional — performance | +| Entity Culling | ✅ Optional — performance | +| Xaero's Minimap | ✅ Optional — waypoint integration | +| Xaero's World Map | ✅ Optional — waypoint integration | +| ViaFabricPlus | ✅ Optional — protocol translation | + +### Dependencies + +- **Required:** Fabric Loader 0.19.3+, Fabric API, Sodium, Java 25+ +- **Optional:** Iris, ImmediatelyFast, ViaFabricPlus, Entity Culling, Xaero's Minimap, Xaero's World Map + +### Installation + +1. Download the jar from [Releases](../../releases) +2. Place it in your `mods/` folder +3. Restart the game + +--- + +## Русский + +### Обзор + +LoVisual — открытый утилитарный и PvP-клиент для Minecraft. Включает настраиваемые боевые, двигательные, визуальные и автоматизированные модули, кастомный ClickGUI, систему тем и API для аддонов. + +Этот репозиторий является **форком** [Combatant Client], который в свою очередь черпал вдохновение из [LiquidBounce], [Meteor Client] и [ThunderHack Recode]. Цель — продолжать разработку, добавлять новые возможности и поддерживать актуальные версии Minecraft. + +### Быстрый старт + +1. Установите Fabric Loader 0.19.3+ для MC 26.2+ +2. Скачайте LoVisual jar +3. Положите в папку `mods/` вместе с Fabric API и Sodium +4. Откройте ClickGUI через **`RShift`** (настраивается) + +### Возможности + +| Возможность | Описание | +|-------------|----------| +| Combat | KillAura, AutoCrystal, AutoAnchor, AutoBed, AutoBow, Criticals, Reach, Velocity, Backtrack и другие | +| Movement | Speed, Flight, ElytraFly, ElytraHelper, Sprint, Timer, Strafe, TargetStrafe, Scaffold, Parkour | +| Visuals | ESP, NameTags, Chams, Tracers, Crosshair, Zoom, Freecam, FullBright, MotionBlur, NoRender | +| Player | AutoTool, AutoEat, AutoArmor, AutoTotem, Scaffold, ClickPearl, FakeLag, XCarry | +| Misc | BetterMinecraft, FakePlayer, HitSounds, Panic, NoSound | +| HUD | Перетаскиваемые элементы: TargetHUD, ModuleList, Watermark, Armor, Potions, Keystrokes, Coords, Notifications, Scoreboard | +| ClickGUI | Полноценный GUI с браузером модулей, редактором настроек, тем и профилей конфигов | +| Профили `.lvisual` | Профили для модулей, HUD и тем — можно шарить между пользователями | +| Addon API | Версионированное API для аддонов | +| Кастомный рендер | UI-рендеринг с MSDF-шрифтами, SVG-иконками и шейдерами | +| Iris/Sodium/Xaero | Опциональная совместимость с популярными модами | + +### Совместимость + +| Мод | Статус | +|-----|--------| +| Sodium | ✅ Требуется | +| Iris | ✅ Опционально — шейдерпаки | +| ImmediatelyFast | ✅ Опционально — производительность | +| Entity Culling | ✅ Опционально — производительность | +| Xaero's Minimap | ✅ Опционально — интеграция waypoint | +| Xaero's World Map | ✅ Опционально — интеграция waypoint | +| ViaFabricPlus | ✅ Опционально — протокол | + +### Зависимости + +- **Обязательные:** Fabric Loader 0.19.3+, Fabric API, Sodium, Java 25+ +- **Опциональные:** Iris, ImmediatelyFast, ViaFabricPlus, Entity Culling, Xaero's Minimap, Xaero's World Map + +### Установка + +1. Скачайте jar из [Releases](../../releases) +2. Положите в папку `mods/` +3. Перезапустите игру + +--- + +## Development + +### Building + +```bash +./gradlew build +``` + +### Docs + +- [Architecture](docs/ARCHITECTURE.md) + +### License + +GNU General Public License v3.0. See [LICENSE](LICENSE). + +--- + +## TODO + +### Фаза 1: Переименование (Rebrand) +- [ ] Переименовать package `combatant` → `dev.loki.lovisual` +- [ ] Переименовать mod ID `combatant` → `lovisual` +- [ ] Сменить название клиента везде (Combatant → LoVisual) +- [ ] Обновить `fabric.mod.json`, `mixins.json` +- [ ] Обновить все нативные сообщения и debug-строки + +### Фаза 2: Косметические изменения +- [ ] Заменить `.cbcfg` → `.lvisual`, путь `config/combatant/` → `config/lovisual/` +- [ ] Префикс команд оставить `@` +- [ ] Новый splash/watermark, иконки, логотип +- [ ] Переписать `theme` под LoVisual-стиль + +### Фаза 3: Рефакторинг +- [ ] Выкинуть ненужное: дебажные фичи, лишние интеграции (iris/sodium compat оставить) +- [ ] Разбить большие файлы (> 1500 строк): Scaffold, KillAura, AutoCrystal, Renderer2D +- [ ] Привести структуру к единому стандарту (нейминг, форматирование) + +### Фаза 4: Модули +- [ ] Добавить модули из `ref/modules/` (Sprint, Velocity, AntiAFK, AutoRespawn, AutoFish, ...) +- [ ] Переписать IRC-модуль (WebSocket) +- [ ] Сделать облачные конфиги (CloudAPI) + +### Фаза 5: Backend (Go) +- [ ] Написать бэкенд с нуля (Go + chi + SQLite) +- [ ] Auth (bcrypt, JWT), профили, друзья +- [ ] WebSocket хаб для IRC +- [ ] REST API для облачных конфигов + +### Фаза 6: Лаунчер (Rust) +- [ ] Написать лаунчер с нуля (Rust + egui) +- [ ] Логин/регистрация, дашборд, настройки +- [ ] Авто-запуск Minecraft с модом +- [ ] Авто-обновление + +### Фаза 7: Polish +- [ ] Звуки (49 звуков) +- [ ] Анимации (SpringAnimator) +- [ ] 6 стилей HUD +- [ ] Load-экран +- [ ] Главное меню (MainMenu) + +[Combatant Client]: https://github.com/pivosos2007/combatant +[LiquidBounce]: https://github.com/CCBlueX/LiquidBounce +[Meteor Client]: https://github.com/MeteorDevelopment/meteor-client +[ThunderHack Recode]: https://github.com/Pan4ur/ThunderHack-Recode diff --git a/combatant-client-26.2/build.gradle b/combatant-client-26.2/build.gradle new file mode 100644 index 0000000..c9771b9 --- /dev/null +++ b/combatant-client-26.2/build.gradle @@ -0,0 +1,399 @@ +plugins { + id 'net.fabricmc.fabric-loom' version '1.17.12' + id 'maven-publish' +} + +repositories { + mavenCentral() + maven { + url = 'https://maven.fabricmc.net/' + } + exclusiveContent { + forRepository { + maven { + name = "Modrinth" + url = "https://api.modrinth.com/maven" + } + } + filter { + includeGroup "maven.modrinth" + } + } + maven { + // ViaFabricPlus / ViaVersion + url = 'https://repo.viaversion.com/everything' + } + maven { + // ViaBedrock snapshots (dependency of ViaFabricPlus) + url = 'https://repo.opencollab.dev/maven-snapshots/' + } + maven { + // ViaBedrock snapshots (alt repo used by Via* stack) + url = 'https://maven.lenni0451.net/snapshots' + } + maven { + // JitPack (for com.github.* artifacts like CubeConverter) + url = 'https://jitpack.io' + } + maven { + url = uri("$rootDir/local-maven") + } +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +def lovisualVulkanDebugEnabled = providers.gradleProperty("lovisualVulkanDebug") + .map { it.toBoolean() } + .orElse(false) +def lovisualDevTaskRequested = gradle.startParameter.taskNames.any { it == "buildDev" || it.endsWith(":buildDev") } +def lovisualDevEnabled = providers.gradleProperty("lovisualDev") + .orElse(providers.gradleProperty("dev")) + .orElse(providers.gradleProperty("devBuild")) + .orElse(lovisualDevTaskRequested ? "true" : "false") + .map { it.toBoolean() } + .orElse(false) +def svgMsdfGeneratedResources = layout.buildDirectory.dir("generated/resources/svgMsdf") +def javetNativeGeneratedResources = layout.buildDirectory.dir("generated/resources/javetNatives") +def javetVersion = "5.0.9" +def targetJavaVersion = 25 + +sourceSets { + main { + resources.srcDir(svgMsdfGeneratedResources) + } + svgMsdfTool { + java.srcDir("src/svgMsdfTool/java") + compileClasspath += sourceSets.main.output.classesDirs + sourceSets.main.compileClasspath + runtimeClasspath += output + compileClasspath + } + dev { + java.srcDir("src/dev/java") + resources.srcDir("src/dev/resources") + compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath + runtimeClasspath += output + compileClasspath + } + vulkanDebug { + java.srcDir("src/vulkanDebug/java") + resources.srcDir("src/vulkanDebug/resources") + compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath + runtimeClasspath += output + compileClasspath + } +} + +configurations { + bundledLibrary + javetNativeBundle + compileOnly.extendsFrom bundledLibrary + runtimeOnly.extendsFrom bundledLibrary + devImplementation.extendsFrom implementation + devCompileOnly.extendsFrom compileOnly + devAnnotationProcessor.extendsFrom annotationProcessor + vulkanDebugImplementation.extendsFrom implementation + vulkanDebugCompileOnly.extendsFrom compileOnly + vulkanDebugAnnotationProcessor.extendsFrom annotationProcessor +} + +loom { + mods { + "lovisual" { + sourceSet sourceSets.main + if (lovisualDevEnabled.get()) { + sourceSet sourceSets.dev + } + if (lovisualVulkanDebugEnabled.get()) { + sourceSet sourceSets.vulkanDebug + } + } + } +} + +dependencies { + compileOnly 'org.projectlombok:lombok:1.18.46' + annotationProcessor 'org.projectlombok:lombok:1.18.46' + testCompileOnly 'org.projectlombok:lombok:1.18.46' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.46' + testImplementation platform('org.junit:junit-bom:5.12.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + devCompileOnly 'org.projectlombok:lombok:1.18.46' + devAnnotationProcessor 'org.projectlombok:lombok:1.18.46' + vulkanDebugCompileOnly 'org.projectlombok:lombok:1.18.46' + vulkanDebugAnnotationProcessor 'org.projectlombok:lombok:1.18.46' + + minecraft "com.mojang:minecraft:${project.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" + + + // Renderer targets. Sodium is required; Iris stays optional but available in dev runtime. + compileOnly "maven.modrinth:sodium:${project.sodium_version}" + compileOnly "maven.modrinth:iris:${project.iris_version}" + compileOnly "maven.modrinth:immediatelyfast:${project.immediatelyfast_version}" + if (providers.gradleProperty("withOptionalModRuntime").map { it.toBoolean() }.orElse(false).get()) { + runtimeOnly "maven.modrinth:sodium:${project.sodium_version}" + runtimeOnly "maven.modrinth:iris:${project.iris_version}" + runtimeOnly "maven.modrinth:immediatelyfast:${project.immediatelyfast_version}" + } + + // Dev-only Xaero API for optional triangulator waypoint compat. + // Pulled from Modrinth Maven so IDE sources/classes follow the selected MC version. + compileOnly "maven.modrinth:xaeros-minimap:${project.xaero_minimap_version}" + compileOnly "maven.modrinth:xaeros-world-map:${project.xaero_worldmap_version}" + + // ViaFabricPlus (optional) + compileOnly "com.viaversion:viafabricplus-api:${project.viafabricplus_version}" + if (providers.gradleProperty("withViaFabricPlusRuntime").map { it.toBoolean() }.orElse(false).get()) { + // Dev/runtime only (not bundled) to test locally. + runtimeOnly "com.viaversion:viafabricplus:${project.viafabricplus_version}" + } + + // Entity Culling (optional): typed compile-time API for shader ESP visibility compat. + compileOnly "maven.modrinth:entityculling:${project.entityculling_version}" + + bundledLibrary "de.marhali:json5-java:3.0.0" + + // Linux MPRIS backend for the built-in media session service. + bundledLibrary "com.github.hypfvieh:dbus-java-core:4.3.1" + bundledLibrary "com.github.hypfvieh:dbus-java-transport-native-unixsocket:4.3.1" + + // Provided by Minecraft runtime; keep it out of the bundled jar. + compileOnly 'com.google.code.gson:gson:2.10.1' + + // Provided by Minecraft/Fabric runtime; do not shade it into the mod jar. + compileOnly 'org.slf4j:slf4j-api:2.0.17' + + bundledLibrary 'io.github.classgraph:classgraph:4.8.165' + + // Javet core only. Native V8 binaries are copied into resources/javet/natives. + bundledLibrary "com.caoccao.javet:javet:${javetVersion}" + + // 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}" + javetNativeBundle "com.caoccao.javet:javet-v8-linux-x86_64:${javetVersion}" + javetNativeBundle "com.caoccao.javet:javet-v8-macos-x86_64:${javetVersion}" + +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.register("validateIrisPatches", JavaExec) { + group = "verification" + description = "Compiles bundled Iris patch manifests against a shaderpack zip without modifying it." + dependsOn classes + classpath = sourceSets.main.runtimeClasspath + mainClass = "dev.loki.lovisual.render.iris.patch.ShaderPatchCompilerCli" + doFirst { + if (!project.hasProperty("shaderpack")) { + throw new GradleException("Pass -Pshaderpack=") + } + args project.property("shaderpack") + } +} + +tasks.register("generateSvgMsdf", Exec) { + group = "render assets" + description = "Generates MTSDF texture assets for tintable SVG icons." + dependsOn tasks.named("compileSvgMsdfToolJava") + + def inputDir = file("src/main/resources/assets/lovisual/svg") + def outputDir = svgMsdfGeneratedResources.map { it.dir("assets/lovisual/svg/msdf") } + def script = file("tools/msdf/GenerateSvgMsdf.ps1") + def preprocessorClasspath = sourceSets.svgMsdfTool.output.classesDirs.asPath + + File.pathSeparator + sourceSets.main.output.classesDirs.asPath + + File.pathSeparator + sourceSets.main.compileClasspath.asPath + def generatorJava = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } + inputs.files(fileTree(inputDir) { + include "**/*.svg" + exclude "msdf/**" + }) + inputs.file(script) + outputs.dir(outputDir) + onlyIf { + if (!script.isFile()) { + logger.warn("SVG MSDF generator missing: ${script}") + return false + } + if (!org.gradle.internal.os.OperatingSystem.current().isWindows()) { + logger.warn("SVG MSDF generation requires Windows (PowerShell); skipping on ${org.gradle.internal.os.OperatingSystem.current().getFamilyName()}") + return false + } + return true + } + doFirst { + outputDir.get().asFile.mkdirs() + } + commandLine "powershell", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", script.absolutePath, + "-InputRoot", inputDir.path, + "-OutRoot", outputDir.get().asFile.path, + "-PreprocessorClasspath", preprocessorClasspath, + "-JavaExe", generatorJava.get().executablePath.asFile.absolutePath +} + + +tasks.register("unpackJavetNatives", Copy) { + group = "runtime assets" + description = "Copies bundled Javet V8 native binaries into generated resources." + into javetNativeGeneratedResources.map { it.dir("javet/natives") } + from({ configurations.javetNativeBundle.collect { zipTree(it) } }) { + include "**/*.dll", "**/*.so", "**/*.dylib" + eachFile { details -> + details.path = details.name + } + includeEmptyDirs = false + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.register("syncJavetNatives", Copy) { + group = "runtime assets" + description = "Copies Javet V8 native binaries into src/main/resources/javet/natives for committing/debugging." + into file("src/main/resources/javet/natives") + from({ configurations.javetNativeBundle.collect { zipTree(it) } }) { + include "**/*.dll", "**/*.so", "**/*.dylib" + eachFile { details -> + details.path = details.name + } + includeEmptyDirs = false + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +processResources { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn tasks.named("generateSvgMsdf") + dependsOn tasks.named("unpackJavetNatives") + from(javetNativeGeneratedResources) + inputs.property "version", project.version + inputs.property "minecraft_version", project.minecraft_version + inputs.property "lovisual_dev", lovisualDevEnabled.get() + inputs.property "lovisual_vulkan_debug", lovisualVulkanDebugEnabled.get() + filteringCharset "UTF-8" + + filesMatching("fabric.mod.json") { + expand "version": project.version, + "minecraft_version": project.minecraft_version, + "loader_version": project.loader_version + } + + doLast { + if (!lovisualDevEnabled.get() && !lovisualVulkanDebugEnabled.get()) return + def metadataFile = new File(destinationDir, "fabric.mod.json") + def metadata = new groovy.json.JsonSlurper().parse(metadataFile) + def mixins = metadata.mixins instanceof java.util.Collection + ? new ArrayList(metadata.mixins) + : new ArrayList() + if (lovisualDevEnabled.get() && !mixins.contains("lovisual-dev.mixins.json")) { + mixins.add("lovisual-dev.mixins.json") + } + if (!mixins.contains("lovisual-vulkan-debug.mixins.json")) { + if (lovisualVulkanDebugEnabled.get()) { + mixins.add("lovisual-vulkan-debug.mixins.json") + } + } + metadata.mixins = mixins + metadataFile.setText( + groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(metadata)) + System.lineSeparator(), + "UTF-8" + ) + } +} + +tasks.named("processDevResources") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.withType(JavaCompile).configureEach { + // ensure that the encoding is set to UTF-8, no matter what the system default is + // this fixes some edge cases with special characters not displaying correctly + // see http://yodaconditions.net/blog/fix-for-java-file-encoding-problems-with-gradle.html + // If Javadoc is generated, this must be specified in that task too. + it.options.encoding = "UTF-8" + if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { + it.options.release.set(targetJavaVersion) + } +} + +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) + } + // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task + // if it is present. + // If you remove this line, sources will not be generated. + withSourcesJar() +} + +tasks.matching { it.name == "sourcesJar" }.configureEach { + dependsOn tasks.named("generateSvgMsdf") + exclude("javet/natives/**") +} + +jar { + manifest { + attributes("Multi-Release": "true") + } + + from("LICENSE") { + rename { "${it}_${project.archives_base_name}" } + } + from { + configurations.bundledLibrary.collect { it.isDirectory() ? it : zipTree(it) } + } + exclude "com/google/gson/**" + exclude "org/slf4j/**" + exclude "META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA" + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + if (lovisualDevEnabled.get()) { + dependsOn tasks.named("compileDevJava"), tasks.named("processDevResources") + from(sourceSets.dev.output) + } + if (lovisualVulkanDebugEnabled.get()) { + dependsOn tasks.named("compileVulkanDebugJava"), tasks.named("processVulkanDebugResources") + from(sourceSets.vulkanDebug.output) + } +} + +// Gradle cannot accept an arbitrary global `--dev` flag for the built-in `build` task +// from build.gradle itself. Use this alias or `-PlovisualDev=true`. +tasks.register("buildDev") { + group = "build" + description = "Builds LoVisual with development source set and dev metadata enabled." + dependsOn "build" +} + +// configure the maven publication +publishing { + publications { + create("mavenJava", MavenPublication) { + artifactId = project.archives_base_name + from components.java + } + } + + // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/pivosos2007/lovisual-client") + credentials { + username = findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") ?: "" + password = findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") ?: System.getenv("GH_TOKEN") ?: "" + } + } + } +} diff --git a/combatant-client-26.2/gradle.properties b/combatant-client-26.2/gradle.properties new file mode 100644 index 0000000..447e3aa --- /dev/null +++ b/combatant-client-26.2/gradle.properties @@ -0,0 +1,20 @@ +# Done to increase the memory available to gradle. +org.gradle.jvmargs=-Xmx2G +# Fabric Properties +# check these on https://modmuss50.me/fabric.html +minecraft_version=26.2 +loader_version=0.19.3 +# Mod Properties +mod_version=0.1.1 +maven_group=pivosos2007 +archives_base_name=lovisual +# Dependencies +# check this on https://modmuss50.me/fabric.html +fabric_version=0.152.2+26.2 +sodium_version=mc26.2-0.9.1-fabric +iris_version=1.11.1+26.2-fabric +immediatelyfast_version=1.16.0+26.2-fabric +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 diff --git a/combatant-client-26.2/gradle/wrapper/gradle-wrapper.properties b/combatant-client-26.2/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1a70468 --- /dev/null +++ b/combatant-client-26.2/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/combatant-client-26.2/lombok.config b/combatant-client-26.2/lombok.config new file mode 100644 index 0000000..f30e45f --- /dev/null +++ b/combatant-client-26.2/lombok.config @@ -0,0 +1,14 @@ +config.stopBubbling = true + +lombok.addLombokGeneratedAnnotation = true +lombok.anyConstructor.addConstructorProperties = true + +lombok.data.flagUsage = error +lombok.var.flagUsage = error +lombok.val.flagUsage = warning +lombok.sneakyThrows.flagUsage = error +lombok.cleanup.flagUsage = error +lombok.experimental.flagUsage = warning + +lombok.equalsAndHashCode.callSuper = warn +lombok.toString.callSuper = warn diff --git a/combatant-client-26.2/settings.gradle b/combatant-client-26.2/settings.gradle new file mode 100644 index 0000000..f91a4fe --- /dev/null +++ b/combatant-client-26.2/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + gradlePluginPortal() + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/features/command/impl/ProfilerCommand.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/features/command/impl/ProfilerCommand.java new file mode 100644 index 0000000..59b5596 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/features/command/impl/ProfilerCommand.java @@ -0,0 +1,301 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.render.engine.profiler.FrameStutterProfiler; +import dev.loki.lovisual.render.engine.profiler.JfrProfiler; +import dev.loki.lovisual.render.engine.profiler.ProfilerSettings; +import dev.loki.lovisual.render.engine.profiler.TracyProfiler; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +@CommandInfo( + id = "profiler", + aliases = "prof", + usage = "@profiler [2d|3d|all|sample|stutter|tracy|jfr] ...", + descriptionKey = "command.profiler.description" +) +public final class ProfilerCommand implements ClientCommand { + + @Override + public boolean isAvailable() { + return RuntimeGate.canRunClientLogic(); + } + + @Override + public boolean execute(CommandContext ctx) { + if (!RuntimeGate.canRunClientLogic()) { + CommandOutput.send("Profiler: disabled in Panic mode."); + return true; + } + String target = ctx.arg(0); + if (target == null + || !(target.equalsIgnoreCase("2d") + || target.equalsIgnoreCase("3d") + || target.equalsIgnoreCase("all") + || target.equalsIgnoreCase("sample") + || target.equalsIgnoreCase("stutter") + || target.equalsIgnoreCase("tracy") + || target.equalsIgnoreCase("jfr"))) { + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + + String mode = ctx.arg(1); + String targetArg = ctx.arg(2); + String detailArg = ctx.arg(3); + if (target.equalsIgnoreCase("jfr")) { + return executeJfr(mode, targetArg); + } + if (target.equalsIgnoreCase("stutter")) { + return executeStutter(mode, targetArg, detailArg); + } + if (target.equalsIgnoreCase("tracy")) { + boolean enable = mode == null || mode.equalsIgnoreCase("on"); + if (mode != null && !mode.equalsIgnoreCase("on") && !mode.equalsIgnoreCase("off")) { + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + boolean active = TracyProfiler.setEnabled(enable); + if (!enable) { + CommandOutput.send("Profiler tracy: off"); + } else if (active) { + CommandOutput.send("Profiler tracy: on (streams to external Tracy viewer, not latest.log)"); + } else { + CommandOutput.send("Profiler tracy: unavailable"); + } + return true; + } + if (mode == null) { + mode = "chat"; + } + + ProfilerSettings.OutputMode modeOutput = ProfilerSettings.parseOutput(mode); + ProfilerSettings.SampleTarget modeTarget = ProfilerSettings.parseTarget(mode); + ProfilerSettings.SampleTarget targetTarget = ProfilerSettings.parseTarget(targetArg); + ProfilerSettings.OutputMode targetOutput = ProfilerSettings.parseOutput(targetArg); + Boolean detailMode = parseDetailMode(detailArg); + + boolean modeIsOutput = modeOutput != null; + boolean modeIsTarget = modeTarget != null; + + if (!modeIsOutput && !modeIsTarget) { + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + + String t = target.toLowerCase(); + if (!t.equals("sample") && !modeIsOutput) { + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + if (t.equals("2d")) { + ProfilerSettings.setOutput2d(modeOutput); + } else if (t.equals("3d")) { + ProfilerSettings.setOutput3d(modeOutput); + } else if (t.equals("sample")) { + if (modeIsTarget) { + ProfilerSettings.setSampleTarget(modeTarget); + if (targetOutput != null) { + ProfilerSettings.setSampleOutput(targetOutput); + modeOutput = targetOutput; + } else { + ProfilerSettings.setSampleOutput(ProfilerSettings.OutputMode.CHAT); + modeOutput = ProfilerSettings.OutputMode.CHAT; + } + } else { + ProfilerSettings.setSampleOutput(modeOutput); + } + if (targetTarget != null) { + ProfilerSettings.setSampleTarget(targetTarget); + } + if (detailMode != null) { + ProfilerSettings.setSampleDetailedStacks(detailMode); + } + } else { + ProfilerSettings.setOutput2d(modeOutput); + ProfilerSettings.setOutput3d(modeOutput); + } + if (t.equals("sample")) { + CommandOutput.send("Profiler sample output: " + modeOutput.name().toLowerCase() + + " (target " + ProfilerSettings.formatTarget(ProfilerSettings.getSampleTarget()) + + ", stacks " + (ProfilerSettings.isSampleDetailedStacks() ? "on" : "off") + ")"); + } else { + CommandOutput.send("Profiler " + t + " output: " + modeOutput.name().toLowerCase()); + } + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex == 1) { + return matchToken(token, List.of("2d", "3d", "all", "sample", "stutter", "tracy", "jfr")); + } + if (argIndex == 2) { + String t = ctx.arg(0); + if (t != null && (t.equalsIgnoreCase("sample") || t.equalsIgnoreCase("stutter"))) { + return matchToken(token, List.of("off", "log", "chat", "2d", "3d", "all")); + } + if (t != null && t.equalsIgnoreCase("tracy")) { + return matchToken(token, List.of("on", "off")); + } + if (t != null && t.equalsIgnoreCase("jfr")) { + return matchToken(token, List.of("start", "stop", "dump", "status")); + } + return matchToken(token, List.of("off", "log", "chat")); + } + if (argIndex == 3) { + String t = ctx.arg(0); + if (t != null && t.equalsIgnoreCase("sample")) { + return matchToken(token, List.of("2d", "3d", "all")); + } + if (t != null && t.equalsIgnoreCase("jfr")) { + String action = ctx.arg(1); + if (action == null || action.equalsIgnoreCase("start")) { + return matchToken(token, List.of("gc", "alloc", "full")); + } + } + } + if (argIndex == 4) { + String t = ctx.arg(0); + if (t != null && t.equalsIgnoreCase("sample")) { + return matchToken(token, List.of("stacks", "nostacks")); + } + } + return List.of(); + } + + private boolean executeStutter(String mode, String thresholdArg, String sampleArg) { + if (mode == null || mode.equalsIgnoreCase("status")) { + CommandOutput.send(FrameStutterProfiler.statusLine()); + return true; + } + ProfilerSettings.OutputMode output = ProfilerSettings.parseOutput(mode); + if (output == null) { + CommandOutput.send("Profiler stutter usage: @profiler stutter [off|log|chat|status] [thresholdMs] [sampleMs]"); + return true; + } + double thresholdMs = parseDouble(thresholdArg, 35.0); + double sampleMs = parseDouble(sampleArg, 2.0); + FrameStutterProfiler.configure(output, thresholdMs, sampleMs); + CommandOutput.send(FrameStutterProfiler.statusLine()); + return true; + } + + private boolean executeJfr(String action, String presetArg) { + if (action == null || action.equalsIgnoreCase("status")) { + CommandOutput.send(JfrProfiler.statusLine()); + return true; + } + if (action.equalsIgnoreCase("start")) { + JfrProfiler.Preset preset = JfrProfiler.Preset.parse(presetArg); + if (preset == null) { + CommandOutput.send("Profiler JFR usage: @profiler jfr start [gc|alloc|full]"); + return true; + } + JfrProfiler.StartResult result = JfrProfiler.start(preset); + if (result.started()) { + CommandOutput.send("Profiler JFR: started " + preset.getId() + " -> " + absolute(result.path())); + return true; + } + if (result.alreadyRunning()) { + CommandOutput.send("Profiler JFR: already running (" + + result.preset().getId() + ", file " + absolute(result.path()) + ")"); + return true; + } + CommandOutput.send("Profiler JFR: unavailable" + + (result.error() == null ? "" : " (" + result.error() + ")")); + return true; + } + if (action.equalsIgnoreCase("dump")) { + JfrProfiler.DumpResult result = JfrProfiler.dumpSnapshot(); + if (result.dumped()) { + CommandOutput.send("Profiler JFR: snapshot -> " + absolute(result.path())); + return true; + } + if (result.notRunning()) { + CommandOutput.send("Profiler JFR: not running"); + return true; + } + CommandOutput.send("Profiler JFR: snapshot failed" + + (result.error() == null ? "" : " (" + result.error() + ")")); + return true; + } + if (action.equalsIgnoreCase("stop")) { + JfrProfiler.StopResult result = JfrProfiler.stop(); + if (result.stopped()) { + long seconds = result.startedAt() == null + ? 0L + : Math.max(0L, Duration.between(result.startedAt(), Instant.now()).toSeconds()); + CommandOutput.send("Profiler JFR: saved " + result.preset().getId() + + " (" + formatDuration(seconds) + ") -> " + absolute(result.path())); + return true; + } + if (result.notRunning()) { + CommandOutput.send("Profiler JFR: not running"); + return true; + } + CommandOutput.send("Profiler JFR: stop failed" + + (result.error() == null ? "" : " (" + result.error() + ")")); + return true; + } + CommandOutput.send("Profiler JFR usage: @profiler jfr [start|stop|dump|status] [gc|alloc|full]"); + return true; + } + + private Boolean parseDetailMode(String raw) { + if (raw == null) return null; + return switch (raw.toLowerCase()) { + case "stacks", "stack", "detail", "detailed" -> Boolean.TRUE; + case "nostacks", "nostack", "basic", "light" -> Boolean.FALSE; + default -> null; + }; + } + + private double parseDouble(String raw, double fallback) { + if (raw == null || raw.isBlank()) return fallback; + try { + double value = Double.parseDouble(raw.trim()); + return Double.isFinite(value) ? value : fallback; + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private List matchToken(String token, List options) { + String lower = token == null ? "" : token.toLowerCase(); + List out = new java.util.ArrayList<>(); + for (String opt : options) { + if (lower.isEmpty() || opt.startsWith(lower)) { + out.add(opt); + } + } + return out; + } + + private String absolute(Path path) { + return path == null ? "" : path.toAbsolutePath().toString(); + } + + private String formatDuration(long totalSeconds) { + long minutes = totalSeconds / 60L; + long seconds = totalSeconds % 60L; + if (minutes <= 0L) { + return seconds + "s"; + } + return minutes + "m " + seconds + "s"; + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/mixins/ProfilersMixin.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/mixins/ProfilersMixin.java new file mode 100644 index 0000000..518fdad --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/mixins/ProfilersMixin.java @@ -0,0 +1,25 @@ +package dev.loki.lovisual.mixins; + +import net.minecraft.util.profiling.InactiveProfiler; +import net.minecraft.util.profiling.Profiler; +import net.minecraft.util.profiling.ProfilerFiller; +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.CallbackInfoReturnable; +import dev.loki.lovisual.render.engine.profiler.DevTracyProfiler; + +@Mixin(Profiler.class) +public abstract class ProfilersMixin { + + @Inject(method = "getDefaultFiller", at = @At("HEAD"), cancellable = true) + private static void combatant$limitVanillaTracyToRenderThread(CallbackInfoReturnable cir) { + if (!DevTracyProfiler.isEnabled()) { + return; + } + if (DevTracyProfiler.shouldTraceCurrentThread()) { + return; + } + cir.setReturnValue(InactiveProfiler.INSTANCE); + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevFrameStutterProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevFrameStutterProfiler.java new file mode 100644 index 0000000..5b68327 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevFrameStutterProfiler.java @@ -0,0 +1,495 @@ +package dev.loki.lovisual.render.engine.profiler; + +import com.sun.management.GarbageCollectionNotificationInfo; +import com.sun.management.GcInfo; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.render.engine.rhi.RhiStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.resource.RenderResourceStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.uniform.UniformAllocatorStatsSnapshot; +import dev.loki.lovisual.runtime.RuntimeGate; + +import javax.management.Notification; +import javax.management.NotificationEmitter; +import javax.management.openmbean.CompositeData; +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.util.*; + +public enum DevFrameStutterProfiler { + ; + private static final long DEFAULT_THRESHOLD_NS = 35_000_000L; + private static final long DEFAULT_SAMPLE_INTERVAL_NS = 2_000_000L; + private static final long GC_CORRELATION_WINDOW_NS = 75_000_000L; + private static final int MAX_STACK_FRAMES = 6; + private static final int MAX_SAMPLES = 2048; + private static final int MAX_GC_EVENTS = 64; + private static final int TOP_STACKS = 8; + + private static final Object LOCK = new Object(); + private static final Sample[] SAMPLES = new Sample[MAX_SAMPLES]; + private static final GcEvent[] GC_EVENTS = new GcEvent[MAX_GC_EVENTS]; + + private static volatile ProfilerSettings.OutputMode output = ProfilerSettings.OutputMode.OFF; + private static volatile long thresholdNs = DEFAULT_THRESHOLD_NS; + private static volatile long sampleIntervalNs = DEFAULT_SAMPLE_INTERVAL_NS; + private static volatile Thread renderThread; + private static volatile Thread samplerThread; + private static volatile boolean running; + private static volatile boolean gcListenerInstalled; + + private static int sampleCursor; + private static int sampleCount; + private static int gcCursor; + private static int gcCount; + private static long lastPresentedNs; + private static long lastLoggedNs; + + public static void configure(ProfilerSettings.OutputMode mode, double thresholdMs, double sampleMs) { + output = mode == null ? ProfilerSettings.OutputMode.OFF : mode; + thresholdNs = clampMs(thresholdMs, 1.0, 5000.0, DEFAULT_THRESHOLD_NS); + sampleIntervalNs = clampMs(sampleMs, 1.0, 100.0, DEFAULT_SAMPLE_INTERVAL_NS); + if (output == ProfilerSettings.OutputMode.OFF) { + stopSampler(); + synchronized (LOCK) { + sampleCursor = 0; + sampleCount = 0; + lastPresentedNs = 0L; + lastLoggedNs = 0L; + } + return; + } + installGcListener(); + ensureSampler(); + } + + public static String statusLine() { + if (output == ProfilerSettings.OutputMode.OFF) { + return "Profiler stutter: off"; + } + return String.format(Locale.ROOT, + "Profiler stutter: %s (threshold %.1f ms, sample %.1f ms)", + output.name().toLowerCase(Locale.ROOT), + thresholdNs / 1_000_000.0, + sampleIntervalNs / 1_000_000.0); + } + + public static boolean isEnabled() { + return output != ProfilerSettings.OutputMode.OFF; + } + + public static void onFramePresented(RhiStatsSnapshot rhi, + UniformAllocatorStatsSnapshot uniforms, + RenderResourceStatsSnapshot resources) { + try { + if (!isEnabled()) return; + if (RuntimeGate.isPanic()) return; + renderThread = Thread.currentThread(); + ensureSampler(); + + long now = System.nanoTime(); + long previous; + synchronized (LOCK) { + previous = lastPresentedNs; + lastPresentedNs = now; + } + if (previous == 0L) return; + + long elapsedNs = now - previous; + if (elapsedNs < thresholdNs) return; + if (now - lastLoggedNs < thresholdNs) return; + lastLoggedNs = now; + + List lines = buildReport(previous, now, elapsedNs, rhi, uniforms, resources); + emit(lines); + } catch (Throwable t) { + ProfilerLog.warn("[FrameStutter] disabled after profiler failure: %s", + t.getClass().getSimpleName() + ": " + t.getMessage()); + configure(ProfilerSettings.OutputMode.OFF, 0.0, 0.0); + } + } + + private static List buildReport(long frameStartNs, + long frameEndNs, + long elapsedNs, + RhiStatsSnapshot rhi, + UniformAllocatorStatsSnapshot uniforms, + RenderResourceStatsSnapshot resources) { + List samples = samplesInWindow(frameStartNs, frameEndNs); + List gcEvents = gcNearWindow(frameStartNs, frameEndNs); + + Minecraft mc = Minecraft.getInstance(); + String screen = mc == null || ClientScreen.current() == null ? "none" : ClientScreen.current().getClass().getSimpleName(); + String world = mc == null || mc.level == null ? "none" : mc.level.dimension().identifier().toString(); + + List lines = new ArrayList<>(); + lines.add(String.format(Locale.ROOT, + "[FrameStutter] frame %.2f ms over %.2f ms, samples %d, screen=%s, world=%s", + elapsedNs / 1_000_000.0, + thresholdNs / 1_000_000.0, + samples.size(), + screen, + world)); + + if (rhi != null) { + long uploaded = rhi.uploadedVertexBytes() + rhi.uploadedIndexBytes(); + lines.add(String.format(Locale.ROOT, + "[FrameStutter] rhi draw=%d fs=%d meshUpload=%d/%s copies=%d/%d ringWrap=%d stall=%d fallback=%d backlog=%d", + rhi.drawCalls(), + rhi.fullscreenPasses(), + rhi.meshUploads(), + formatBytes(uploaded), + rhi.textureFastCopies(), + rhi.textureShaderCopies(), + rhi.ringWraps(), + rhi.ringStalls(), + rhi.immediateFallbackUploads(), + rhi.dynamicArenaBacklogEvents())); + } + if (uniforms != null || resources != null) { + long uniformWrites = uniforms == null ? 0L : uniforms.writes(); + long uniformBytes = uniforms == null ? 0L : uniforms.uploadedBytes(); + int retireBacklog = resources == null ? 0 : resources.retirementBacklog(); + int leaks = resources == null ? 0 : resources.leakedResources(); + lines.add(String.format(Locale.ROOT, + "[FrameStutter] uniforms writes=%d/%s resourceBacklog=%d leaks=%d", + uniformWrites, + formatBytes(uniformBytes), + retireBacklog, + leaks)); + } + + if (gcEvents.isEmpty()) { + lines.add("[FrameStutter] gc nearby: none"); + } else { + for (GcEvent gc : gcEvents) { + double relativeEndMs = (gc.endNs - frameStartNs) / 1_000_000.0; + lines.add(String.format(Locale.ROOT, + "[FrameStutter] gc nearby: %s %s %.2f ms at %+,.2f ms, freed %s", + gc.name, + gc.cause, + (double) gc.durationMs, + relativeEndMs, + formatBytes(gc.freedBytes))); + } + } + + appendSampleSummary(lines, samples); + return lines; + } + + private static void appendSampleSummary(List lines, List samples) { + if (samples.isEmpty()) { + lines.add("[FrameStutter] sample top: none"); + return; + } + Map counts = new HashMap<>(); + int glWait = 0; + int glUpload = 0; + int glDraw = 0; + int glState = 0; + for (Sample sample : samples) { + counts.merge(sample.stack, 1, Integer::sum); + if (sample.stack.startsWith("GL WAIT:")) { + glWait++; + } else if (sample.stack.startsWith("GL UPLOAD:")) { + glUpload++; + } else if (sample.stack.startsWith("GL DRAW:")) { + glDraw++; + } else if (sample.stack.startsWith("GL STATE:")) { + glState++; + } + } + lines.add(String.format(Locale.ROOT, + "[FrameStutter] gl samples wait/upload/draw/state=%d/%d/%d/%d", + glWait, + glUpload, + glDraw, + glState)); + + List> entries = new ArrayList<>(counts.entrySet()); + entries.sort((a, b) -> Integer.compare(b.getValue(), a.getValue())); + lines.add("[FrameStutter] sample top:"); + int top = Math.min(TOP_STACKS, entries.size()); + for (int i = 0; i < top; i++) { + Map.Entry entry = entries.get(i); + double pct = entry.getValue() * 100.0 / samples.size(); + lines.add(String.format(Locale.ROOT, + "[FrameStutter] %d) %s (%d, %.1f%%)", + i + 1, + entry.getKey(), + entry.getValue(), + pct)); + } + } + + private static List samplesInWindow(long startNs, long endNs) { + List out = new ArrayList<>(); + synchronized (LOCK) { + for (int i = 0; i < sampleCount; i++) { + Sample sample = SAMPLES[i]; + if (sample != null && sample.timeNs >= startNs && sample.timeNs <= endNs) { + out.add(sample); + } + } + } + out.sort((a, b) -> Long.compare(a.timeNs, b.timeNs)); + return out; + } + + private static List gcNearWindow(long startNs, long endNs) { + long from = startNs - GC_CORRELATION_WINDOW_NS; + long to = endNs + GC_CORRELATION_WINDOW_NS; + List out = new ArrayList<>(); + synchronized (LOCK) { + for (int i = 0; i < gcCount; i++) { + GcEvent event = GC_EVENTS[i]; + if (event != null && event.endNs >= from && event.endNs <= to) { + out.add(event); + } + } + } + out.sort((a, b) -> Long.compare(a.endNs, b.endNs)); + return out; + } + + private static void ensureSampler() { + if (running || output == ProfilerSettings.OutputMode.OFF) return; + running = true; + samplerThread = new Thread(DevFrameStutterProfiler::sampleLoop, "Combatant-DevFrameStutterProfiler"); + samplerThread.setDaemon(true); + samplerThread.start(); + } + + private static void stopSampler() { + running = false; + Thread thread = samplerThread; + samplerThread = null; + if (thread != null) { + thread.interrupt(); + } + renderThread = null; + } + + private static void sampleLoop() { + long lastSampleNs = 0L; + while (running) { + if (output == ProfilerSettings.OutputMode.OFF || RuntimeGate.isPanic()) { + sleepMs(20L); + continue; + } + Thread target = renderThread; + if (target == null) { + sleepMs(10L); + continue; + } + long now = System.nanoTime(); + if (now - lastSampleNs >= sampleIntervalNs) { + recordSample(now, target); + lastSampleNs = now; + } + sleepMs(1L); + } + } + + private static void recordSample(long now, Thread target) { + StackTraceElement[] stack = target.getStackTrace(); + if (stack == null || stack.length == 0) return; + String key = buildKey(stack); + if (key == null || key.isBlank()) return; + synchronized (LOCK) { + SAMPLES[sampleCursor] = new Sample(now, key); + sampleCursor = (sampleCursor + 1) % MAX_SAMPLES; + if (sampleCount < MAX_SAMPLES) { + sampleCount++; + } + } + } + + private static String buildKey(StackTraceElement[] stack) { + StringBuilder sb = new StringBuilder(320); + String glCat = glCategory(stack); + if (glCat != null) { + sb.append("GL ").append(glCat).append(": "); + } + int added = 0; + for (StackTraceElement e : stack) { + if (added >= MAX_STACK_FRAMES) break; + if (isNoise(e)) continue; + if (added > 0) sb.append(" <= "); + sb.append(formatElement(e)); + added++; + } + return added == 0 ? null : sb.toString(); + } + + private static String glCategory(StackTraceElement[] stack) { + for (StackTraceElement e : stack) { + String cls = e.getClassName(); + String method = e.getMethodName(); + if (!isGlFrame(cls)) continue; + if (method == null) continue; + if (method.equals("glfwSwapBuffers") + || method.equals("glClientWaitSync") + || method.equals("nglClientWaitSync") + || method.equals("glWaitSync") + || method.equals("glFinish")) { + return "WAIT"; + } + if (method.startsWith("glDraw") || method.startsWith("glMultiDraw")) { + return "DRAW"; + } + if (method.contains("BufferSubData") + || method.contains("BufferData") + || method.contains("TexImage") + || method.contains("TexSubImage") + || method.contains("FlushMapped") + || method.contains("MapBuffer") + || method.contains("MapBufferRange") + || method.contains("UnmapBuffer")) { + return "UPLOAD"; + } + if (method.startsWith("glUseProgram") + || method.startsWith("glBind") + || method.startsWith("glTexParameter") + || method.startsWith("glBlend") + || method.startsWith("glEnable") + || method.startsWith("glDisable") + || method.startsWith("glDepth") + || method.startsWith("glColorMask") + || method.startsWith("glViewport") + || method.startsWith("glScissor") + || method.startsWith("glActiveTexture") + || method.startsWith("glUniform") + || method.startsWith("glVertexAttrib")) { + return "STATE"; + } + } + return null; + } + + private static boolean isGlFrame(String cls) { + return cls != null + && (cls.startsWith("org.lwjgl.opengl.") + || cls.startsWith("org.lwjgl.glfw.") + || cls.startsWith("com.mojang.blaze3d.platform.") + || cls.startsWith("com.mojang.blaze3d.systems.")); + } + + private static boolean isNoise(StackTraceElement e) { + String cls = e.getClassName(); + return cls.startsWith("java.lang.Thread") + || cls.startsWith("jdk.internal") + || cls.startsWith("sun.") + || cls.startsWith("dev.loki.lovisual.render.engine.profiler.DevFrameStutterProfiler"); + } + + private static String formatElement(StackTraceElement e) { + String file = e.getFileName() == null ? "Unknown" : e.getFileName(); + return e.getClassName() + "." + e.getMethodName() + "(" + file + ":" + e.getLineNumber() + ")"; + } + + private static void installGcListener() { + if (gcListenerInstalled) return; + synchronized (LOCK) { + if (gcListenerInstalled) return; + for (GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) { + if (bean instanceof NotificationEmitter emitter) { + emitter.addNotificationListener(DevFrameStutterProfiler::onGcNotification, null, null); + } + } + gcListenerInstalled = true; + } + } + + private static void onGcNotification(Notification notification, Object handback) { + if (output == ProfilerSettings.OutputMode.OFF) return; + if (!GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals(notification.getType())) return; + Object data = notification.getUserData(); + if (!(data instanceof CompositeData compositeData)) return; + try { + GarbageCollectionNotificationInfo info = GarbageCollectionNotificationInfo.from(compositeData); + GcInfo gcInfo = info.getGcInfo(); + long freed = usedBefore(gcInfo) - usedAfter(gcInfo); + GcEvent event = new GcEvent( + System.nanoTime(), + info.getGcName(), + info.getGcCause(), + Math.max(0L, gcInfo.getDuration()), + Math.max(0L, freed)); + synchronized (LOCK) { + GC_EVENTS[gcCursor] = event; + gcCursor = (gcCursor + 1) % MAX_GC_EVENTS; + if (gcCount < MAX_GC_EVENTS) { + gcCount++; + } + } + } catch (Throwable ignored) { + } + } + + private static long usedBefore(GcInfo info) { + return used(info == null ? null : info.getMemoryUsageBeforeGc()); + } + + private static long usedAfter(GcInfo info) { + return used(info == null ? null : info.getMemoryUsageAfterGc()); + } + + private static long used(Map usage) { + if (usage == null || usage.isEmpty()) return 0L; + long total = 0L; + for (MemoryUsage value : usage.values()) { + if (value != null && value.getUsed() > 0L) { + total += value.getUsed(); + } + } + return total; + } + + private static void emit(List lines) { + ProfilerSettings.OutputMode mode = output; + if (mode == ProfilerSettings.OutputMode.OFF || lines == null || lines.isEmpty()) return; + if (mode == ProfilerSettings.OutputMode.LOG) { + for (String line : lines) { + ProfilerLog.info("%s", line); + } + return; + } + if (mode == ProfilerSettings.OutputMode.CHAT) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null) return; + for (String line : lines) { + mc.gui.hud.getChat().addClientSystemMessage(Component.literal(line)); + } + } + } + + private static long clampMs(double valueMs, double minMs, double maxMs, long fallbackNs) { + if (!Double.isFinite(valueMs) || valueMs <= 0.0) return fallbackNs; + double clamped = Math.max(minMs, Math.min(maxMs, valueMs)); + return (long) (clamped * 1_000_000.0); + } + + private static String formatBytes(long bytes) { + if (bytes < 1024L) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024.0) return String.format(Locale.ROOT, "%.1f KiB", kb); + return String.format(Locale.ROOT, "%.2f MiB", kb / 1024.0); + } + + private static void sleepMs(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ignored) { + } + } + + private record Sample(long timeNs, String stack) { + } + + private record GcEvent(long endNs, String name, String cause, long durationMs, long freedBytes) { + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevGlSyncTracker.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevGlSyncTracker.java new file mode 100644 index 0000000..9f40611 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevGlSyncTracker.java @@ -0,0 +1,340 @@ +package dev.loki.lovisual.render.engine.profiler; + +import java.util.*; + +public enum DevGlSyncTracker { + ; + + private static final int MAX_FENCES = 512; + private static final int TOP_PHASES = 4; + private static final int TOP_STACKS = 3; + private static final int MAX_STACK_FRAMES = 8; + private static final int MAX_SLOW_WAITS = 3; + private static final long SLOW_WAIT_THRESHOLD_NS = 1_000_000L; + + private static final Object LOCK = new Object(); + private static final LinkedHashMap FENCE_INFOS = new LinkedHashMap<>(); + private static final Map WAIT_BY_FENCE_PHASE = new HashMap<>(); + private static final Map WAIT_BY_WAIT_PHASE = new HashMap<>(); + private static final Map WAIT_TIME_BY_FENCE_PHASE = new HashMap<>(); + private static final Map WAIT_TIME_BY_WAIT_PHASE = new HashMap<>(); + private static final Map WAIT_BY_CREATE_STACK = new HashMap<>(); + private static final List SLOW_WAITS = new ArrayList<>(); + private static final ThreadLocal WAIT_STATE = + ThreadLocal.withInitial(WaitState::new); + private static int waitTotal; + private static long waitTimeTotalNs; + private static int tracyWaitCallsFrame; + private static long tracyWaitTimeFrameNs; + + public static void onFence(long sync) { + if (!shouldTrackWaits()) return; + if (sync == 0L) return; + String phase = DevProfilerPhase.current(); + String createStack = shouldCaptureStacks() ? captureStackTrace() : null; + synchronized (LOCK) { + FENCE_INFOS.put(sync, new FenceInfo(phase, createStack)); + trimFences(); + } + } + + public static void onWaitStart(long sync) { + if (!shouldTrackWaits()) return; + WaitState state = WAIT_STATE.get(); + state.startNs = System.nanoTime(); + state.waitPhase = DevProfilerPhase.current(); + state.waitStack = null; + FenceInfo fenceInfo; + synchronized (LOCK) { + fenceInfo = FENCE_INFOS.remove(sync); + } + state.fencePhase = fenceInfo == null || fenceInfo.phase == null ? "unknown" : fenceInfo.phase; + state.createStack = fenceInfo == null ? null : fenceInfo.createStack; + state.active = true; + } + + public static void onWaitEnd() { + WaitState state = WAIT_STATE.get(); + if (state == null || !state.active) return; + state.active = false; + long durationNs = System.nanoTime() - state.startNs; + if (durationNs < 0L) durationNs = 0L; + if (!shouldTrackWaits()) return; + String fencePhase = state.fencePhase == null ? "unknown" : state.fencePhase; + String waitPhase = state.waitPhase == null ? "unknown" : state.waitPhase; + boolean captureStacks = shouldCaptureStacks(); + String createStack = captureStacks ? normalizeStack(state.createStack) : null; + String waitStack = captureStacks && durationNs >= SLOW_WAIT_THRESHOLD_NS ? captureStackTrace() : null; + synchronized (LOCK) { + WAIT_BY_FENCE_PHASE.merge(fencePhase, 1, Integer::sum); + WAIT_BY_WAIT_PHASE.merge(waitPhase, 1, Integer::sum); + WAIT_TIME_BY_FENCE_PHASE.merge(fencePhase, durationNs, Long::sum); + WAIT_TIME_BY_WAIT_PHASE.merge(waitPhase, durationNs, Long::sum); + if (captureStacks) { + StackAggregate aggregate = WAIT_BY_CREATE_STACK.computeIfAbsent( + createStack, + key -> new StackAggregate(fencePhase, key) + ); + aggregate.count += 1; + aggregate.timeNs += durationNs; + } + if (durationNs >= SLOW_WAIT_THRESHOLD_NS) { + recordSlowWait(new SlowWait( + durationNs, + fencePhase, + waitPhase, + createStack, + captureStacks ? normalizeStack(waitStack) : null + )); + } + waitTotal += 1; + waitTimeTotalNs += durationNs; + tracyWaitCallsFrame += 1; + tracyWaitTimeFrameNs += durationNs; + } + } + + public static void emitTracyFrame() { + if (!DevTracyProfiler.isEnabled()) return; + int calls; + long totalNs; + synchronized (LOCK) { + calls = tracyWaitCallsFrame; + totalNs = tracyWaitTimeFrameNs; + tracyWaitCallsFrame = 0; + tracyWaitTimeFrameNs = 0L; + } + DevTracyProfiler.plotGlWait(totalNs / 1_000_000.0, calls); + } + + public static List drainLines() { + if (!DevSamplingProfiler.isActive()) return List.of(); + List lines = new ArrayList<>(); + Map byFence; + Map byWait; + Map timeByFence; + Map timeByWait; + List createStacks; + List slowWaits; + int total; + long totalNs; + synchronized (LOCK) { + if (waitTotal == 0) return List.of(); + total = waitTotal; + totalNs = waitTimeTotalNs; + byFence = new HashMap<>(WAIT_BY_FENCE_PHASE); + byWait = new HashMap<>(WAIT_BY_WAIT_PHASE); + timeByFence = new HashMap<>(WAIT_TIME_BY_FENCE_PHASE); + timeByWait = new HashMap<>(WAIT_TIME_BY_WAIT_PHASE); + createStacks = new ArrayList<>(WAIT_BY_CREATE_STACK.values()); + slowWaits = new ArrayList<>(SLOW_WAITS); + WAIT_BY_FENCE_PHASE.clear(); + WAIT_BY_WAIT_PHASE.clear(); + WAIT_TIME_BY_FENCE_PHASE.clear(); + WAIT_TIME_BY_WAIT_PHASE.clear(); + WAIT_BY_CREATE_STACK.clear(); + SLOW_WAITS.clear(); + waitTotal = 0; + waitTimeTotalNs = 0L; + } + lines.add(String.format("gl wait calls: %d", total)); + lines.add("gl wait phase (fence): " + formatTop(byFence, total)); + lines.add("gl wait phase (wait): " + formatTop(byWait, total)); + if (totalNs > 0L) { + lines.add(String.format("gl wait time total: %s", formatDuration(totalNs))); + lines.add("gl wait time (fence): " + formatTopTime(timeByFence, totalNs)); + lines.add("gl wait time (wait): " + formatTopTime(timeByWait, totalNs)); + } + String createStackSummary = formatTopCreateStacks(createStacks, totalNs); + if (createStackSummary != null) { + lines.add("gl wait source (create): " + createStackSummary); + } + if (!slowWaits.isEmpty()) { + lines.add("gl wait slow traces:"); + for (int i = 0; i < slowWaits.size(); i++) { + SlowWait slow = slowWaits.get(i); + lines.add(String.format( + " %d) %s fence=%s wait=%s", + i + 1, + formatDuration(slow.durationNs), + slow.fencePhase, + slow.waitPhase + )); + if (slow.createStack != null) { + lines.add(" create: " + slow.createStack); + } + if (slow.waitStack != null) { + lines.add(" wait: " + slow.waitStack); + } + } + } + return lines; + } + + private static String formatTop(Map src, int total) { + if (src.isEmpty()) return "n/a"; + List> list = new ArrayList<>(src.entrySet()); + list.sort((a, b) -> Integer.compare(b.getValue(), a.getValue())); + int top = Math.min(TOP_PHASES, list.size()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < top; i++) { + Map.Entry e = list.get(i); + double pct = total == 0 ? 0.0 : (e.getValue() * 100.0) / total; + if (sb.length() > 0) sb.append(", "); + sb.append(e.getKey()).append('=').append(e.getValue()) + .append(String.format(" (%.1f%%)", pct)); + } + return sb.toString(); + } + + private static String formatTopTime(Map src, long totalNs) { + if (src.isEmpty()) return "n/a"; + List> list = new ArrayList<>(src.entrySet()); + list.sort((a, b) -> Long.compare(b.getValue(), a.getValue())); + int top = Math.min(TOP_PHASES, list.size()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < top; i++) { + Map.Entry e = list.get(i); + double pct = totalNs == 0L ? 0.0 : (e.getValue() * 100.0) / totalNs; + if (sb.length() > 0) sb.append(", "); + sb.append(e.getKey()).append('=').append(formatDuration(e.getValue())) + .append(String.format(" (%.1f%%)", pct)); + } + return sb.toString(); + } + + private static String formatDuration(long ns) { + double ms = ns / 1_000_000.0; + return String.format("%.2fms", ms); + } + + private static void trimFences() { + if (FENCE_INFOS.size() <= MAX_FENCES) return; + int toRemove = FENCE_INFOS.size() - MAX_FENCES; + var it = FENCE_INFOS.keySet().iterator(); + while (toRemove > 0 && it.hasNext()) { + it.next(); + it.remove(); + toRemove--; + } + } + + private static String formatTopCreateStacks(List stacks, long totalNs) { + if (stacks == null || stacks.isEmpty()) return null; + stacks.sort((a, b) -> Long.compare(b.timeNs, a.timeNs)); + int top = Math.min(TOP_STACKS, stacks.size()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < top; i++) { + StackAggregate aggregate = stacks.get(i); + double pct = totalNs == 0L ? 0.0 : (aggregate.timeNs * 100.0) / totalNs; + if (sb.length() > 0) sb.append(" | "); + sb.append(aggregate.fencePhase) + .append('=') + .append(formatDuration(aggregate.timeNs)) + .append(" x") + .append(aggregate.count) + .append(String.format(" (%.1f%%)", pct)) + .append(" :: ") + .append(aggregate.createStack); + } + return sb.toString(); + } + + private static void recordSlowWait(SlowWait slowWait) { + SLOW_WAITS.add(slowWait); + SLOW_WAITS.sort((a, b) -> Long.compare(b.durationNs, a.durationNs)); + while (SLOW_WAITS.size() > MAX_SLOW_WAITS) { + SLOW_WAITS.remove(SLOW_WAITS.size() - 1); + } + } + + private static String captureStackTrace() { + StackTraceElement[] stack = Thread.currentThread().getStackTrace(); + StringBuilder sb = new StringBuilder(256); + int added = 0; + for (StackTraceElement e : stack) { + if (added >= MAX_STACK_FRAMES) break; + if (isStackNoise(e)) continue; + if (added > 0) sb.append(" <= "); + sb.append(formatElement(e)); + added++; + } + return added == 0 ? "unknown" : sb.toString(); + } + + private static String normalizeStack(String stack) { + return stack == null || stack.isBlank() ? "unknown" : stack; + } + + private static boolean shouldCaptureStacks() { + return ProfilerSettings.isSampleDetailedStacks(); + } + + private static boolean shouldTrackWaits() { + return DevSamplingProfiler.isActive() || DevTracyProfiler.isEnabled(); + } + + private static boolean isStackNoise(StackTraceElement e) { + String cls = e.getClassName(); + String method = e.getMethodName(); + return cls.equals(Thread.class.getName()) + || cls.startsWith("java.lang.Thread") + || cls.startsWith("jdk.internal") + || cls.startsWith("sun.") + || cls.startsWith("org.lwjgl.opengl.GL") + || cls.startsWith("org.lwjgl.system.JNI") + || cls.equals(DevGlSyncTracker.class.getName()) + || cls.equals("dev.loki.lovisual.mixins.GlStateManagerMixin") + || (cls.equals("com.mojang.blaze3d.opengl.GlStateManager") + && ("_glFenceSync".equals(method) || "_glClientWaitSync".equals(method))); + } + + private static String formatElement(StackTraceElement e) { + String cls = e.getClassName(); + int idx = cls.lastIndexOf('.'); + String simple = idx >= 0 ? cls.substring(idx + 1) : cls; + return simple + "." + e.getMethodName() + "(" + e.getFileName() + ":" + e.getLineNumber() + ")"; + } + + public static void reset() { + synchronized (LOCK) { + FENCE_INFOS.clear(); + WAIT_BY_FENCE_PHASE.clear(); + WAIT_BY_WAIT_PHASE.clear(); + WAIT_TIME_BY_FENCE_PHASE.clear(); + WAIT_TIME_BY_WAIT_PHASE.clear(); + WAIT_BY_CREATE_STACK.clear(); + SLOW_WAITS.clear(); + waitTotal = 0; + waitTimeTotalNs = 0L; + } + } + + private record FenceInfo(String phase, String createStack) { + } + + private static final class StackAggregate { + final String fencePhase; + final String createStack; + int count; + long timeNs; + + StackAggregate(String fencePhase, String createStack) { + this.fencePhase = fencePhase; + this.createStack = createStack; + } + } + + private record SlowWait(long durationNs, String fencePhase, String waitPhase, String createStack, + String waitStack) { + } + + private static final class WaitState { + long startNs; + String fencePhase; + String waitPhase; + String createStack; + String waitStack; + boolean active; + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevJfrProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevJfrProfiler.java new file mode 100644 index 0000000..7a45537 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevJfrProfiler.java @@ -0,0 +1,253 @@ +package dev.loki.lovisual.render.engine.profiler; + +import jdk.jfr.EventSettings; +import jdk.jfr.Recording; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public enum DevJfrProfiler { + ; + + private static final DateTimeFormatter FILE_TIME = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + + private static Recording recording; + private static Preset activePreset; + private static Instant startedAt; + private static Path outputPath; + + public static synchronized StartResult start(Preset preset) { + if (recording != null) { + return StartResult.alreadyRunning(activePreset, outputPath, startedAt); + } + try { + Path dir = ensureOutputDirectory(); + Path file = dir.resolve(defaultFileName(preset, false)); + + Recording next = new Recording(); + next.setName("Combatant " + preset.getId()); + next.setToDisk(true); + next.setDumpOnExit(false); + configure(next, preset); + next.start(); + + recording = next; + activePreset = preset; + startedAt = Instant.now(); + outputPath = file; + + ProfilerLog.info("JFR started (%s) -> %s", preset.getId(), file.toAbsolutePath()); + return StartResult.started(preset, file, startedAt); + } catch (Throwable t) { + ProfilerLog.warn("JFR unavailable: %s", t.getClass().getSimpleName() + ": " + t.getMessage()); + return StartResult.failed(t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + + public static synchronized DumpResult dumpSnapshot() { + if (recording == null) { + return DumpResult.notRunningResult(); + } + try { + Path dir = ensureOutputDirectory(); + Path snapshot = dir.resolve(defaultFileName(activePreset, true)); + recording.dump(snapshot); + ProfilerLog.info("JFR snapshot dumped -> %s", snapshot.toAbsolutePath()); + return DumpResult.dumped(snapshot); + } catch (Throwable t) { + ProfilerLog.warn("JFR snapshot failed: %s", t.getClass().getSimpleName() + ": " + t.getMessage()); + return DumpResult.failed(t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + + public static synchronized StopResult stop() { + if (recording == null) { + return StopResult.notRunningResult(); + } + Recording current = recording; + Preset preset = activePreset; + Instant started = startedAt; + Path destination = outputPath; + recording = null; + activePreset = null; + startedAt = null; + outputPath = null; + try { + current.stop(); + Files.createDirectories(destination.getParent()); + current.dump(destination); + current.close(); + ProfilerLog.info("JFR stopped (%s) -> %s", preset.getId(), destination.toAbsolutePath()); + return StopResult.stopped(preset, destination, started); + } catch (Throwable t) { + try { + current.close(); + } catch (Throwable ignored) { + } + ProfilerLog.warn("JFR stop failed: %s", t.getClass().getSimpleName() + ": " + t.getMessage()); + return StopResult.failed(t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + + public static synchronized String statusLine() { + if (recording == null) { + return "Profiler JFR: off"; + } + long seconds = Math.max(0L, Duration.between(startedAt, Instant.now()).toSeconds()); + return "Profiler JFR: on (" + activePreset.getId() + + ", " + formatDuration(seconds) + + ", file " + outputPath.toAbsolutePath() + ")"; + } + + private static void configure(Recording recording, Preset preset) { + enable(recording, "jdk.GarbageCollection", true, null, null); + enable(recording, "jdk.GCHeapSummary", false, null, null); + enable(recording, "jdk.MetaspaceSummary", false, null, null); + enable(recording, "jdk.CPULoad", false, Duration.ofSeconds(1), null); + enable(recording, "jdk.JavaThreadStatistics", false, Duration.ofSeconds(1), null); + if (preset.capturesAllocations()) { + enable(recording, "jdk.ObjectAllocationSample", true, null, null); + } + if (preset == Preset.FULL) { + enable(recording, "jdk.ExecutionSample", true, Duration.ofMillis(20), null); + enable(recording, "jdk.NativeMethodSample", true, Duration.ofMillis(20), null); + enable(recording, "jdk.ThreadPark", true, null, Duration.ofMillis(1)); + enable(recording, "jdk.JavaMonitorEnter", true, null, Duration.ofMillis(1)); + } + } + + private static void enable( + Recording recording, + String eventName, + boolean stackTrace, + Duration period, + Duration threshold + ) { + try { + EventSettings settings = recording.enable(eventName); + settings = stackTrace ? settings.withStackTrace() : settings.withoutStackTrace(); + if (period != null) { + settings = settings.withPeriod(period); + } + if (threshold != null) { + settings = settings.withThreshold(threshold); + } + } catch (IllegalArgumentException ignored) { + // Some runtimes ship a slightly different event set. + } + } + + private static Path ensureOutputDirectory() throws Exception { + Minecraft mc = Minecraft.getInstance(); + Path dir; + if (mc != null && mc.gameDirectory != null) { + dir = mc.gameDirectory.toPath().resolve("profile").resolve("jfr"); + } else { + dir = Path.of("profile", "jfr"); + } + Files.createDirectories(dir); + return dir; + } + + private static String defaultFileName(Preset preset, boolean snapshot) { + String timestamp = FILE_TIME.format(LocalDateTime.now(ZoneId.systemDefault())); + String suffix = snapshot ? "-snapshot" : ""; + return "combatant-" + preset.getId() + suffix + "-" + timestamp + ".jfr"; + } + + private static String formatDuration(long totalSeconds) { + long minutes = totalSeconds / 60L; + long seconds = totalSeconds % 60L; + if (minutes <= 0L) { + return seconds + "s"; + } + return minutes + "m " + seconds + "s"; + } + + public enum Preset { + GC("gc"), + ALLOC("alloc"), + FULL("full"); + + private final String id; + + Preset(String id) { + this.id = id; + } + + public static Preset parse(String raw) { + if (raw == null || raw.isBlank()) { + return FULL; + } + return switch (raw.toLowerCase()) { + case "gc" -> GC; + case "alloc", "allocation", "allocs" -> ALLOC; + case "full", "all", "profile" -> FULL; + default -> null; + }; + } + + public String id() { + return id; + } + + public String getId() { + return id; + } + + public boolean capturesAllocations() { + return this == ALLOC || this == FULL; + } + } + + public record StartResult(boolean started, boolean alreadyRunning, Preset preset, Path path, Instant startedAt, + String error) { + private static StartResult started(Preset preset, Path path, Instant startedAt) { + return new StartResult(true, false, preset, path, startedAt, null); + } + + private static StartResult alreadyRunning(Preset preset, Path path, Instant startedAt) { + return new StartResult(false, true, preset, path, startedAt, null); + } + + private static StartResult failed(String error) { + return new StartResult(false, false, null, null, null, error); + } + } + + public record DumpResult(boolean dumped, boolean notRunning, Path path, String error) { + private static DumpResult dumped(Path path) { + return new DumpResult(true, false, path, null); + } + + private static DumpResult notRunningResult() { + return new DumpResult(false, true, null, null); + } + + private static DumpResult failed(String error) { + return new DumpResult(false, false, null, error); + } + } + + public record StopResult(boolean stopped, boolean notRunning, Preset preset, Path path, Instant startedAt, + String error) { + private static StopResult stopped(Preset preset, Path path, Instant startedAt) { + return new StopResult(true, false, preset, path, startedAt, null); + } + + private static StopResult notRunningResult() { + return new StopResult(false, true, null, null, null, null); + } + + private static StopResult failed(String error) { + return new StopResult(false, false, null, null, null, error); + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevProfilerPhase.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevProfilerPhase.java new file mode 100644 index 0000000..987d043 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevProfilerPhase.java @@ -0,0 +1,67 @@ +package dev.loki.lovisual.render.engine.profiler; + +import java.util.ArrayDeque; + +public enum DevProfilerPhase { + ; + + private static final ThreadLocal> STACK = + ThreadLocal.withInitial(ArrayDeque::new); + + public static Scope scope(String label) { + if (label == null || label.isBlank()) return Scope.NOOP; + begin(label); + return new Scope(true); + } + + public static void begin(String label) { + if (label == null || label.isBlank()) return; + if (!DevSamplingProfiler.isActive()) return; + STACK.get().push(label); + } + + public static void end(String label) { + if (label == null || label.isBlank()) return; + ArrayDeque stack = STACK.get(); + if (stack == null || stack.isEmpty()) return; + String top = stack.peek(); + if (label.equals(top)) { + pop(); + } + } + + public static String current() { + ArrayDeque stack = STACK.get(); + if (stack == null || stack.isEmpty()) return "unknown"; + String v = stack.peek(); + return v == null || v.isBlank() ? "unknown" : v; + } + + public static void clearCurrent() { + ArrayDeque stack = STACK.get(); + if (stack != null) { + stack.clear(); + } + } + + private static void pop() { + ArrayDeque stack = STACK.get(); + if (stack == null || stack.isEmpty()) return; + stack.pop(); + } + + public static final class Scope implements AutoCloseable { + private static final Scope NOOP = new Scope(false); + private final boolean active; + + private Scope(boolean active) { + this.active = active; + } + + @Override + public void close() { + if (!active) return; + pop(); + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderCostProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderCostProfiler.java new file mode 100644 index 0000000..66bf6e1 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderCostProfiler.java @@ -0,0 +1,457 @@ +package dev.loki.lovisual.render.engine.profiler; + +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; + +import java.util.*; + +/** + * Low-overhead owner/effect profiler for the new RHI path. + * + *

The existing DevRenderProfiler2D/3D tree answers "which Java section is hot". + * This profiler answers the practical render question: which phase/widget/node/effect/pass + * consumed CPU time and caused RHI work (draws, uploads, fullscreen passes, copies, uniforms).

+ */ +public enum DevRenderCostProfiler { + ; + private static final int MAX_BUCKETS = 768; + private static final long NS_IN_MS = 1_000_000L; + private static final int DEFAULT_LOG_INTERVAL_FRAMES = 120; + private static final int DEFAULT_LOG_TOP = 20; + private static final double DEFAULT_LOG_MIN_MS = 0.02; + + private static final String PROP_ENABLED = "combatant.profiler.renderCost"; + private static final String PROP_LOG = "combatant.profiler.renderCost.log"; + private static final String PROP_INTERVAL = "combatant.profiler.renderCost.interval"; + private static final String PROP_TOP = "combatant.profiler.renderCost.top"; + private static final String PROP_MIN_MS = "combatant.profiler.renderCost.minMs"; + + private static final LinkedHashMap buckets = new LinkedHashMap<>(); + private static final HashMap labelCache = new HashMap<>(256); + private static final ThreadLocal> scopeStack = ThreadLocal.withInitial(ArrayDeque::new); + private static long frameId = Long.MIN_VALUE; + private static long lastLoggedFrameId = Long.MIN_VALUE; + private static boolean active; + private static Snapshot snapshot = Snapshot.EMPTY; + + public static void beginFrame(long newFrameId) { + if (frameId == newFrameId) return; + frameId = newFrameId; + active = isProfilerEnabled(); + if (!active) { + snapshot = Snapshot.EMPTY; + return; + } + buckets.clear(); + scopeStack.get().clear(); + } + + public static void endFrame() { + if (!active) return; + snapshot = Snapshot.from(frameId, buckets); + emitLogIfDue(snapshot); + active = false; + } + + public static Snapshot snapshot() { + return snapshot; + } + + public static boolean isEnabled() { + return active; + } + + public static boolean isConfigured() { + return Boolean.getBoolean(PROP_ENABLED); + } + + public static Scope scope(String domain, String name) { + if (!active) return Scope.NOOP; + String d = clean(domain, "unknown"); + String n = clean(name, "unnamed"); + String owner = clean(DevProfilerPhase.current(), "unknown"); + Bucket bucket = bucket(d, owner, n); + if (bucket == null) return Scope.NOOP; + Scope scope = new Scope(bucket, System.nanoTime(), Counters.capture(), true); + scopeStack.get().push(scope); + return scope; + } + + public static Scope phase(String label) { + return scope("phase", label); + } + + public static Scope uiNode(Object label) { + return scope("ui.node", String.valueOf(label)); + } + + public static Scope uiRuntime(String label) { + return scope("ui.runtime", label); + } + + public static Scope uiEffect(String label) { + return scope("ui.effect", label); + } + + public static Scope postPass(String label) { + return scope("post.pass", label); + } + + public static Scope rhiDraw(String label) { + return scope("rhi.draw", label); + } + + public static Scope itemRender(String label) { + return scope("item.render", label); + } + + public static Scope worldEffect(String label) { + return scope("world.effect", label); + } + + public static List debugLines(String title, String domainPrefix, int limit, double minMs) { + Snapshot snap = snapshot; + if (snap == null || snap.entries.isEmpty()) return List.of(); + String prefix = domainPrefix == null ? "" : domainPrefix; + List filtered = new ArrayList<>(); + for (Entry entry : snap.entries) { + if (prefix.isEmpty() || entry.domain.startsWith(prefix)) { + filtered.add(entry); + } + } + if (filtered.isEmpty()) return List.of(); + + filtered.sort(Comparator + .comparingLong((Entry e) -> e.totalNs).reversed() + .thenComparingLong((Entry e) -> e.uploadedBytes).reversed() + .thenComparingLong((Entry e) -> e.drawCalls).reversed()); + + List lines = new ArrayList<>(); + lines.add(title + ":"); + int count = 0; + for (Entry e : filtered) { + double ms = e.totalNs / (double) NS_IN_MS; + if (ms < minMs) continue; + lines.add(String.format(Locale.ROOT, + " %d) [%s] %s:%s %.2f ms max %.2f calls %d | draw %d upload %d/%s fs %d copy %d/%d uni %d/%s", + count + 1, + shorten(e.owner, 42), + e.domain, + shorten(e.name, 56), + ms, + e.maxNs / (double) NS_IN_MS, + e.calls, + e.drawCalls, + e.meshUploads, + formatBytes(e.uploadedBytes), + e.fullscreenPasses, + e.fastCopies, + e.shaderCopies, + e.uniformWrites, + formatBytes(e.uniformBytes))); + count++; + if (count >= limit) break; + } + if (count == 0) return List.of(); + return lines; + } + + private static boolean isProfilerEnabled() { + return Boolean.getBoolean(PROP_ENABLED); + } + + private static boolean shouldLogToLatest() { + if (!Boolean.getBoolean(PROP_ENABLED)) return false; + return Boolean.parseBoolean(System.getProperty(PROP_LOG, "true")); + } + + private static void emitLogIfDue(Snapshot snap) { + if (!shouldLogToLatest()) return; + if (snap == null || snap.entries.isEmpty()) return; + + int interval = intProperty(PROP_INTERVAL, DEFAULT_LOG_INTERVAL_FRAMES, 1, 60_000); + if (lastLoggedFrameId != Long.MIN_VALUE && snap.frameId - lastLoggedFrameId < interval) return; + lastLoggedFrameId = snap.frameId; + + int top = intProperty(PROP_TOP, DEFAULT_LOG_TOP, 1, 200); + double minMs = doubleProperty(PROP_MIN_MS, DEFAULT_LOG_MIN_MS, 0.0, 1_000.0); + + ProfilerLog.info("[RenderCost] frame=%d buckets=%d interval=%d top=%d minMs=%.3f", snap.frameId, snap.entries.size(), interval, top, minMs); + emitDomainToLog("phases", "phase", top, minMs); + emitDomainToLog("ui runtime", "ui.runtime", top, minMs); + emitDomainToLog("ui nodes", "ui.node", top, minMs); + emitDomainToLog("ui effects", "ui.effect", top, minMs); + emitDomainToLog("item render", "item.render", top, minMs); + emitDomainToLog("world effects", "world.effect", top, minMs); + emitDomainToLog("post passes", "post.pass", top, minMs); + emitDomainToLog("rhi draws", "rhi.draw", top, minMs); + } + + private static void emitDomainToLog(String title, String prefix, int top, double minMs) { + List lines = debugLines("[RenderCost] " + title, prefix, top, minMs); + for (String line : lines) { + ProfilerLog.info("%s", line); + } + } + + private static int intProperty(String key, int fallback, int min, int max) { + String raw = System.getProperty(key); + if (raw == null || raw.isBlank()) return fallback; + try { + int value = Integer.parseInt(raw.trim()); + return Math.max(min, Math.min(max, value)); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static double doubleProperty(String key, double fallback, double min, double max) { + String raw = System.getProperty(key); + if (raw == null || raw.isBlank()) return fallback; + try { + double value = Double.parseDouble(raw.trim()); + if (!Double.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, value)); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static Bucket bucket(String domain, String owner, String name) { + String key = domain + '\u001F' + owner + '\u001F' + name; + Bucket existing = buckets.get(key); + if (existing != null) return existing; + if (buckets.size() >= MAX_BUCKETS) { + key = "overflow\u001F" + owner + "\u001Fother"; + existing = buckets.get(key); + if (existing != null) return existing; + if (buckets.size() >= MAX_BUCKETS + 1) return null; + Bucket overflow = new Bucket("overflow", owner, "other"); + buckets.put(key, overflow); + return overflow; + } + Bucket created = new Bucket(domain, owner, name); + buckets.put(key, created); + return created; + } + + private static String clean(String raw, String fallback) { + if (raw == null || raw.isBlank()) return fallback; + String s = raw.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ').trim(); + return s.isEmpty() ? fallback : s; + } + + private static String shorten(String s, int max) { + if (s == null) return ""; + if (s.length() <= max) return s; + if (max <= 3) return s.substring(0, max); + return s.substring(0, max - 3) + "..."; + } + + private static String formatBytes(long bytes) { + if (bytes < 1024L) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024.0) return String.format(Locale.ROOT, "%.1f KiB", kb); + return String.format(Locale.ROOT, "%.2f MiB", kb / 1024.0); + } + + public static final class Scope implements AutoCloseable { + private static final Scope NOOP = new Scope(null, 0L, Counters.ZERO, false); + private final Bucket bucket; + private final long startNs; + private final Counters begin; + private final boolean enabled; + private boolean closed; + private long childNs; + private Counters childCounters = Counters.ZERO; + + private Scope(Bucket bucket, long startNs, Counters begin, boolean enabled) { + this.bucket = bucket; + this.startNs = startNs; + this.begin = begin; + this.enabled = enabled; + } + + @Override + public void close() { + if (!enabled || closed || bucket == null) return; + closed = true; + long elapsed = Math.max(0L, System.nanoTime() - startNs); + Counters rawDelta = begin.deltaTo(Counters.capture()); + + Deque stack = scopeStack.get(); + if (!stack.isEmpty() && stack.peek() == this) { + stack.pop(); + } else { + stack.remove(this); + } + + if (!stack.isEmpty()) { + Scope parent = stack.peek(); + parent.childNs += elapsed; + parent.childCounters = parent.childCounters.plus(rawDelta); + } + + long bucketNs = elapsed; + Counters bucketCounters = rawDelta; + if ("ui.node".equals(bucket.domain)) { + bucketNs = Math.max(0L, elapsed - childNs); + bucketCounters = rawDelta.minus(childCounters); + } + bucket.add(bucketNs, bucketCounters); + } + } + + private static final class Bucket { + final String domain; + final String owner; + final String name; + long calls; + long totalNs; + long maxNs; + long drawCalls; + long meshUploads; + long fullscreenPasses; + long fastCopies; + long shaderCopies; + long uploadedBytes; + long uniformWrites; + long uniformBytes; + + private Bucket(String domain, String owner, String name) { + this.domain = domain; + this.owner = owner; + this.name = name; + } + + void add(long ns, Counters d) { + calls++; + totalNs += ns; + maxNs = Math.max(maxNs, ns); + if (d == null) return; + drawCalls += d.drawCalls; + meshUploads += d.meshUploads; + fullscreenPasses += d.fullscreenPasses; + fastCopies += d.fastCopies; + shaderCopies += d.shaderCopies; + uploadedBytes += d.uploadedBytes; + uniformWrites += d.uniformWrites; + uniformBytes += d.uniformBytes; + } + } + + private record Counters(long drawCalls, + long meshUploads, + long fullscreenPasses, + long fastCopies, + long shaderCopies, + long uploadedBytes, + long uniformWrites, + long uniformBytes) { + static final Counters ZERO = new Counters(0, 0, 0, 0, 0, 0, 0, 0); + + static Counters capture() { + try { + long uploaded = CombatantRenderSystem.rhi().stats().uploadedVertexBytes() + + CombatantRenderSystem.rhi().stats().uploadedIndexBytes(); + return new Counters( + CombatantRenderSystem.rhi().stats().drawCalls(), + CombatantRenderSystem.rhi().stats().meshUploads(), + CombatantRenderSystem.rhi().stats().fullscreenPasses(), + CombatantRenderSystem.rhi().stats().textureFastCopies(), + CombatantRenderSystem.rhi().stats().textureShaderCopies(), + uploaded, + CombatantRenderSystem.uniforms().stats().writes(), + CombatantRenderSystem.uniforms().stats().uploadedBytes() + ); + } catch (Throwable ignored) { + return ZERO; + } + } + + Counters deltaTo(Counters end) { + if (end == null) return ZERO; + return new Counters( + Math.max(0L, end.drawCalls - drawCalls), + Math.max(0L, end.meshUploads - meshUploads), + Math.max(0L, end.fullscreenPasses - fullscreenPasses), + Math.max(0L, end.fastCopies - fastCopies), + Math.max(0L, end.shaderCopies - shaderCopies), + Math.max(0L, end.uploadedBytes - uploadedBytes), + Math.max(0L, end.uniformWrites - uniformWrites), + Math.max(0L, end.uniformBytes - uniformBytes) + ); + } + + Counters plus(Counters other) { + if (other == null) return this; + return new Counters( + drawCalls + other.drawCalls, + meshUploads + other.meshUploads, + fullscreenPasses + other.fullscreenPasses, + fastCopies + other.fastCopies, + shaderCopies + other.shaderCopies, + uploadedBytes + other.uploadedBytes, + uniformWrites + other.uniformWrites, + uniformBytes + other.uniformBytes + ); + } + + Counters minus(Counters other) { + if (other == null) return this; + return new Counters( + Math.max(0L, drawCalls - other.drawCalls), + Math.max(0L, meshUploads - other.meshUploads), + Math.max(0L, fullscreenPasses - other.fullscreenPasses), + Math.max(0L, fastCopies - other.fastCopies), + Math.max(0L, shaderCopies - other.shaderCopies), + Math.max(0L, uploadedBytes - other.uploadedBytes), + Math.max(0L, uniformWrites - other.uniformWrites), + Math.max(0L, uniformBytes - other.uniformBytes) + ); + } + } + + public record Snapshot(long frameId, List entries) { + static final Snapshot EMPTY = new Snapshot(Long.MIN_VALUE, List.of()); + + static Snapshot from(long frameId, Map buckets) { + if (buckets == null || buckets.isEmpty()) return EMPTY; + List out = new ArrayList<>(buckets.size()); + for (Bucket b : buckets.values()) { + out.add(new Entry( + b.domain, + b.owner, + b.name, + b.calls, + b.totalNs, + b.maxNs, + b.drawCalls, + b.meshUploads, + b.fullscreenPasses, + b.fastCopies, + b.shaderCopies, + b.uploadedBytes, + b.uniformWrites, + b.uniformBytes + )); + } + return new Snapshot(frameId, List.copyOf(out)); + } + } + + public record Entry(String domain, + String owner, + String name, + long calls, + long totalNs, + long maxNs, + long drawCalls, + long meshUploads, + long fullscreenPasses, + long fastCopies, + long shaderCopies, + long uploadedBytes, + long uniformWrites, + long uniformBytes) { + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderFrameProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderFrameProfiler.java new file mode 100644 index 0000000..42c63bb --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderFrameProfiler.java @@ -0,0 +1,135 @@ +package dev.loki.lovisual.render.engine.profiler; + +import dev.loki.lovisual.render.engine.core.RenderPhase; +import dev.loki.lovisual.render.engine.rhi.RhiStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.uniform.UniformAllocatorStatsSnapshot; + +/** + * Single profiler bridge for the RHI/FrameGraph lifecycle. + * Existing DevRenderProfiler2D/3D trees are preserved, but phase ownership moves here. + */ +public enum DevRenderFrameProfiler { + ; + private static boolean worldFrameOpen; + private static boolean uiFrameOpen; + + public static void beginFrame(long frameId) { + // The sampling profiler is still driven by 2D/3D beginFrame calls below. + DevRenderCostProfiler.beginFrame(frameId); + } + + public static PhaseScope phase(RenderPhase phase, String label) { + RenderPhase actualPhase = phase == null ? RenderPhase.NONE : phase; + String actualLabel = label == null || label.isBlank() ? defaultLabel(actualPhase) : label; + + DevProfilerPhase.Scope cpu = DevProfilerPhase.scope(actualLabel); + DevRenderCostProfiler.Scope cost = DevRenderCostProfiler.phase(actualLabel); + DevTracyGpuProfiler.Scope gpu = DevTracyGpuProfiler.beginZone(actualLabel); + AutoCloseable tree = beginTreeSection(actualPhase, actualLabel); + return new PhaseScope(cpu, cost, gpu, tree); + } + + public static void endFrame(RhiStatsSnapshot rhi, UniformAllocatorStatsSnapshot uniforms) { + DevRenderCostProfiler.endFrame(); + if (worldFrameOpen) { + DevRenderProfiler3D.endFrame(); + worldFrameOpen = false; + } + if (uiFrameOpen) { + DevRenderProfiler2D.endFrame(); + uiFrameOpen = false; + } + DevGlSyncTracker.emitTracyFrame(); + DevTracyProfiler.markFrame(); + } + + private static AutoCloseable beginTreeSection(RenderPhase phase, String label) { + if (isWorld(phase)) { + if (!worldFrameOpen) { + DevRenderProfiler3D.beginFrame("world"); + worldFrameOpen = true; + } + return DevRenderProfiler3D.section(stripDomain(label)); + } + if (isUi(phase)) { + if (!uiFrameOpen) { + DevRenderProfiler2D.beginFrame("ui"); + uiFrameOpen = true; + } + return DevRenderProfiler2D.section(stripDomain(label)); + } + return () -> { + }; + } + + private static boolean isWorld(RenderPhase phase) { + return phase == RenderPhase.WORLD_BEFORE_ENTITIES + || phase == RenderPhase.WORLD_AFTER_ENTITIES + || phase == RenderPhase.WORLD_BEFORE_TRANSLUCENT + || phase == RenderPhase.WORLD_AFTER_TRANSLUCENT + || phase == RenderPhase.WORLD_POST_PRE_HAND + || phase == RenderPhase.WORLD_POST_HAND; + } + + private static boolean isUi(RenderPhase phase) { + return phase == RenderPhase.HUD_CAPTURE + || phase == RenderPhase.HUD_MAIN + || phase == RenderPhase.HUD_EFFECTS + || phase == RenderPhase.SCREEN_TOP + || phase == RenderPhase.SCREEN; + } + + private static String defaultLabel(RenderPhase phase) { + return switch (phase) { + case WORLD_BEFORE_ENTITIES -> "3d:world_before_entities"; + case WORLD_AFTER_ENTITIES -> "3d:world_after_entities"; + case WORLD_BEFORE_TRANSLUCENT -> "3d:world_before_translucent"; + case WORLD_AFTER_TRANSLUCENT -> "3d:world_after_translucent"; + case WORLD_POST_PRE_HAND -> "3d:post_pre_hand"; + case WORLD_POST_HAND -> "3d:post_hand"; + case HUD_CAPTURE -> "2d:hud_capture"; + case HUD_MAIN -> "2d:hud_main"; + case HUD_EFFECTS -> "2d:hud_effects"; + case SCREEN_TOP -> "2d:screen_top"; + case SCREEN -> "2d:screen"; + case LEGACY_SHADOW_TERRAIN -> "3d:shadow_terrain_legacy"; + default -> "render:none"; + }; + } + + private static String stripDomain(String label) { + int idx = label.indexOf(':'); + if (idx >= 0 && idx + 1 < label.length()) { + return label.substring(idx + 1); + } + return label; + } + + public static final class PhaseScope implements AutoCloseable { + private final DevProfilerPhase.Scope cpu; + private final DevRenderCostProfiler.Scope cost; + private final DevTracyGpuProfiler.Scope gpu; + private final AutoCloseable tree; + private boolean closed; + + private PhaseScope(DevProfilerPhase.Scope cpu, DevRenderCostProfiler.Scope cost, DevTracyGpuProfiler.Scope gpu, AutoCloseable tree) { + this.cpu = cpu; + this.cost = cost; + this.gpu = gpu; + this.tree = tree; + } + + @Override + public void close() { + if (closed) return; + closed = true; + try { + if (tree != null) tree.close(); + } catch (Exception ignored) { + } + if (gpu != null) gpu.close(); + if (cost != null) cost.close(); + if (cpu != null) cpu.close(); + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler2D.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler2D.java new file mode 100644 index 0000000..f7d5ff2 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler2D.java @@ -0,0 +1,499 @@ +package dev.loki.lovisual.render.engine.profiler; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.render.engine.command.UiStatsSnapshot; +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.rhi.RhiStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.resource.RenderResourceStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.uniform.UniformAllocatorStatsSnapshot; +import dev.loki.lovisual.render.engine.text.TextCommandStatsSnapshot; +import dev.loki.lovisual.render.engine.text.TextRenderSystem; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.*; + +public enum DevRenderProfiler2D { + ; + + private static final long NS_IN_MS = 1_000_000L; + private static final int DEFAULT_TOP_LIMIT = 6; + private static final int DEFAULT_TREE_DEPTH = 32; + private static final double DEFAULT_MIN_MS = 0.0; + private static final long DEFAULT_EMIT_INTERVAL_NS = 1_000_000_000L; + private static final ArrayDeque stack = new ArrayDeque<>(); + private static boolean active; + private static Node root; + private static Node snapshot; + private static long lastEmitNs; + + public static boolean isEnabled() { + return getOutputModeRaw() != ProfilerSettings.OutputMode.OFF; + } + + public static void beginFrame(String name) { + DevSamplingProfiler.onClientFrame(DevSamplingProfiler.Target.UI); + if (!isEnabled()) return; + CachedUiScriptRuntime.resetFrameStats(); + MeshBuilder.resetFrameStats(); + active = true; + root = new Node(name, null); + stack.clear(); + stack.push(root); + root.startNs = System.nanoTime(); + } + + public static boolean beginFrameIfNeeded(String name) { + DevSamplingProfiler.onClientFrame(DevSamplingProfiler.Target.UI); + if (!isEnabled() || active) return false; + beginFrame(name); + return true; + } + + public static void endFrame() { + if (!isEnabled() || !active) return; + long now = System.nanoTime(); + while (stack.size() > 1) { + endSection(); + } + Node r = stack.peek(); + if (r != null) { + r.calls = Math.max(1, r.calls); + r.totalNs += now - r.startNs; + r.startNs = 0L; + snapshot = r; + DevTracyProfiler.plotUiFrame(r.totalNs / (double) NS_IN_MS, r.countNodes()); + } + active = false; + emitIfDue(now); + } + + public static Section section(String name) { + if (!isEnabled() || !active) return Section.NOOP; + beginSection(name); + return new Section(true); + } + + private static void beginSection(String name) { + Node parent = stack.peek(); + if (parent == null) return; + Node child = parent.children.computeIfAbsent(name, n -> new Node(n, parent)); + child.calls += 1; + child.startNs = System.nanoTime(); + stack.push(child); + } + + private static void endSection() { + Node node = stack.poll(); + if (node == null) return; + long now = System.nanoTime(); + node.totalNs += now - node.startNs; + node.startNs = 0L; + } + + public static List getDebugLines() { + return getDebugLines(DEFAULT_TOP_LIMIT, DEFAULT_TREE_DEPTH, DEFAULT_MIN_MS); + } + + public static List getDebugLines(int topLimit, int depthLimit, double minMs) { + if (!isEnabled()) return List.of(); + Node snap = snapshot; + if (snap == null) return List.of(); + + List lines = new ArrayList<>(); + double totalMs = snap.totalNs / (double) NS_IN_MS; + lines.add(String.format("ui prof: %.2f ms, nodes %d", totalMs, snap.countNodes())); + appendRuntimeCounters(lines); + + List flat = new ArrayList<>(); + collectFlat(snap, flat); + flat.sort(Comparator.comparingLong((Node n) -> n.totalNs).reversed()); + + int top = Math.min(topLimit, flat.size()); + if (top > 0) { + lines.add("ui prof top:"); + for (int i = 0; i < top; i++) { + Node n = flat.get(i); + double ms = n.totalNs / (double) NS_IN_MS; + if (ms < minMs) break; + double selfMs = selfNs(n) / (double) NS_IN_MS; + Node hot = hottestChild(n); + if (hot != null) { + double hotMs = hot.totalNs / (double) NS_IN_MS; + lines.add(String.format( + " %d) %s %.2f ms (self %.2f, hot %s %.2f, %d)", + i + 1, + nodePath(n), + ms, + selfMs, + hot.name, + hotMs, + n.calls + )); + } else { + lines.add(String.format( + " %d) %s %.2f ms (self %.2f, %d)", + i + 1, + nodePath(n), + ms, + selfMs, + n.calls + )); + } + } + } + + lines.add("ui prof tree:"); + appendTreeAscii(lines, snap, "", 0, depthLimit, minMs); + return lines; + } + + private static void appendRuntimeCounters(List lines) { + CachedUiScriptRuntime.FrameStatsSnapshot ui = CachedUiScriptRuntime.frameStatsSnapshot(); + if (ui.bakeCalls() > 0 || ui.jsRenders() > 0 || ui.layoutPasses() > 0 || ui.propPatches() > 0 || ui.boundsPatches() > 0) { + lines.add(String.format( + "ui runtime: bake %d, js %d, layout %d, propPatch %d/%d, boundsPatch %d/%d, blocked %d, fail %d", + ui.bakeCalls(), + ui.jsRenders(), + ui.layoutPasses(), + ui.propPatchPasses(), + ui.propPatches(), + ui.boundsPatchPasses(), + ui.boundsPatches(), + ui.blockedCalls(), + ui.failedRenders() + )); + } + + TextCommandStatsSnapshot textStats = CombatantRenderSystem.textStatsSnapshot(); + if (textStats.recorded() > 0 || textStats.meshUploads() > 0 || textStats.glyphs() > 0) { + lines.add(String.format( + "text: rec/sub %d/%d, backend vanilla/bitmap/msdf/world %d/%d/%d/%d, effect/clip %d/%d, glyphs %d, meshUploads %d, router hit/miss/fallback %d/%d/%d, adjacent %d, uploaded v/i %s/%s", + textStats.recorded(), + textStats.submitted(), + textStats.vanilla(), + textStats.bitmap(), + textStats.msdf(), + textStats.world(), + textStats.effect(), + textStats.clipped(), + textStats.glyphs(), + textStats.meshUploads(), + textStats.routerCacheHits(), + textStats.routerCacheMisses(), + textStats.routerFallbackMisses(), + textStats.adjacentBatchedCommands(), + formatBytes(textStats.uploadedVertexBytes()), + formatBytes(textStats.uploadedIndexBytes()) + )); + } + var glyphStats = TextRenderSystem.glyphAtlases().stats(); + if (glyphStats.pageCount() > 0 || glyphStats.dirtyUploads() > 0) { + lines.add(String.format( + "glyph atlas: pages %d, glyphs %d, dirtyUploads %d, uploaded %s", + glyphStats.pageCount(), + glyphStats.glyphCount(), + glyphStats.dirtyUploads(), + formatBytes(glyphStats.uploadedBytes()) + )); + } + + UiStatsSnapshot uiDraw = Renderer2D.getUiStatsSnapshot(); + if (uiDraw.recordedCommands() > 0 || uiDraw.compiledBatches() > 0) { + lines.add(String.format( + "ui commands: total %d, shape/path/tex/text/item/effect %d/%d/%d/%d/%d/%d, compiled %d, backend %d", + uiDraw.recordedCommands(), + uiDraw.shapeCommands(), + uiDraw.pathCommands(), + uiDraw.textureCommands(), + uiDraw.textCommands(), + uiDraw.itemCommands(), + uiDraw.effectCommands(), + uiDraw.compiledBatches(), + uiDraw.backendCommands() + )); + } + + Renderer2D.BatchStats batch = Renderer2D.getBatchStats(); + int frameBatches = batch.getFrameBatches(); + int frameDraws = batch.getFrameDraws(); + int frameVertices = batch.getFrameVertices(); + int frameIndices = batch.getFrameIndices(); + Map flushReasons = batch.getFrameFlushReasons(); + if (frameBatches > 0 || frameDraws > 0 || frameVertices > 0 || frameIndices > 0 || !flushReasons.isEmpty()) { + lines.add(String.format( + "ui batch: batches %d, draws %d, vertices %d, indices %d, pool %d, last %d/%d/%d, flush %s %s", + frameBatches, + frameDraws, + frameVertices, + frameIndices, + batch.getPoolTotal(), + batch.getLastOrder(), + batch.getLastDraws(), + batch.getLastVertices(), + batch.getLastFlushReason(), + formatFlushReasons(flushReasons) + )); + } + if (!batch.getLastError().isEmpty()) { + lines.add("ui batch error: " + batch.getLastError()); + } + + MeshBuilder.FrameStatsSnapshot mesh = MeshBuilder.frameStatsSnapshot(); + if (mesh.initialAllocations() > 0 || mesh.vertexReallocs() > 0 || mesh.indexReallocs() > 0) { + lines.add(String.format( + "ui mesh: alloc %d, realloc v/i %d/%d, bytes %s, max v/i %s/%s", + mesh.initialAllocations(), + mesh.vertexReallocs(), + mesh.indexReallocs(), + formatBytes(mesh.allocatedBytes()), + formatBytes(mesh.maxVertexBytes()), + formatBytes(mesh.maxIndexBytes()) + )); + } + + appendRhiCounters(lines); + var immediatelyFast = CombatantRenderSystem.immediatelyFastSnapshot(); + if (immediatelyFast.modLoaded()) { + lines.add(immediatelyFast.shortLine()); + } + appendCostCounters(lines); + } + + private static String formatFlushReasons(Map reasons) { + if (reasons == null || reasons.isEmpty()) return "{}"; + List parts = new ArrayList<>(); + for (Map.Entry entry : reasons.entrySet()) { + if (entry.getValue() != null && entry.getValue() > 0) { + parts.add(entry.getKey().name().toLowerCase(Locale.ROOT) + "=" + entry.getValue()); + } + } + return parts.isEmpty() ? "{}" : "{" + String.join(",", parts) + "}"; + } + + private static void appendCostCounters(List lines) { + List phase = DevRenderCostProfiler.debugLines("render cost phases", "phase", 6, 0.02); + if (!phase.isEmpty()) lines.addAll(phase); + List runtime = DevRenderCostProfiler.debugLines("ui cost runtime", "ui.runtime", 8, 0.02); + if (!runtime.isEmpty()) lines.addAll(runtime); + List nodes = DevRenderCostProfiler.debugLines("ui cost nodes", "ui.node", 10, 0.02); + if (!nodes.isEmpty()) lines.addAll(nodes); + List effects = DevRenderCostProfiler.debugLines("ui cost effects", "ui.effect", 8, 0.02); + if (!effects.isEmpty()) lines.addAll(effects); + List items = DevRenderCostProfiler.debugLines("ui cost item render", "item.render", 8, 0.02); + if (!items.isEmpty()) lines.addAll(items); + List rhi = DevRenderCostProfiler.debugLines("ui cost rhi draws", "rhi.draw", 8, 0.02); + if (!rhi.isEmpty()) lines.addAll(rhi); + } + + private static void appendRhiCounters(List lines) { + RhiStatsSnapshot rhi = CombatantRenderSystem.rhiStatsSnapshot(); + UniformAllocatorStatsSnapshot uniforms = CombatantRenderSystem.uniformStatsSnapshot(); + if (rhi.drawCalls() > 0 || rhi.renderPasses() > 0 || rhi.meshUploads() > 0 || rhi.fullscreenPasses() > 0 + || rhi.textureFastCopies() > 0 || rhi.textureShaderCopies() > 0) { + lines.add(String.format( + "rhi: draws %d, passes/switches %d/%d, meshUploads %d, fullscreen %d, copies fast/shader %d/%d, uploaded v/i %s/%s", + rhi.drawCalls(), + rhi.renderPasses(), + rhi.renderPassAttachmentSwitches(), + rhi.meshUploads(), + rhi.fullscreenPasses(), + rhi.textureFastCopies(), + rhi.textureShaderCopies(), + formatBytes(rhi.uploadedVertexBytes()), + formatBytes(rhi.uploadedIndexBytes()) + )); + } + if (rhi.ringWraps() > 0 || rhi.ringStalls() > 0 || rhi.immediateFallbackUploads() > 0) { + lines.add(String.format( + "rhi upload: wraps %d, stalls %d, immediateFallback %d, arena reuse/retire %d/%d", + rhi.ringWraps(), + rhi.ringStalls(), + rhi.immediateFallbackUploads(), + rhi.dynamicArenaReuses(), + rhi.dynamicArenaRetires() + )); + } + if (rhi.legacyPathUses() > 0) { + lines.add("rhi legacy paths: " + rhi.legacyPathUses() + " " + rhi.legacyPathBreakdown()); + } + if (uniforms.writes() > 0 || uniforms.ringRotations() > 0 || uniforms.staleReadMisses() > 0) { + lines.add(String.format( + "uniforms: writes %d, bytes %s, activeStreams %d, rotations %d, grows %d, staleMiss %d", + uniforms.writes(), + formatBytes(uniforms.uploadedBytes()), + uniforms.activeStreams(), + uniforms.ringRotations(), + uniforms.ringGrows(), + uniforms.staleReadMisses() + )); + } + RenderResourceStatsSnapshot resources = CombatantRenderSystem.resourceStatsSnapshot(); + if (resources.persistentFramebuffers() > 0 || resources.temporaryFramebuffers() > 0 + || resources.retirementBacklog() > 0 || resources.leakedResources() > 0) { + lines.add(String.format( + "resources: fb persistent/temp %d/%d, create/resize %d/%d, borrow/release %d/%d, retire backlog/leaks %d/%d", + resources.persistentFramebuffers(), + resources.temporaryFramebuffers(), + resources.framebufferCreates(), + resources.framebufferResizes(), + resources.framebufferBorrows(), + resources.framebufferReleases(), + resources.retirementBacklog(), + resources.leakedResources() + )); + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024L) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024.0) return String.format(Locale.ROOT, "%.1f KiB", kb); + return String.format(Locale.ROOT, "%.2f MiB", kb / 1024.0); + } + + private static void collectFlat(Node node, List out) { + for (Node child : node.children.values()) { + out.add(child); + collectFlat(child, out); + } + } + + private static void appendTreeAscii(List out, Node node, String indent, int depth, int maxDepth, double minMs) { + if (node == null) return; + double ms = node.totalNs / (double) NS_IN_MS; + if (depth > 0 && ms < minMs) return; + double selfMs = selfNs(node) / (double) NS_IN_MS; + String line = depth == 0 + ? String.format("%s %.2f ms (self %.2f, %d)", node.name, ms, selfMs, node.calls) + : String.format("%s|-- %s %.2f ms (self %.2f, %d)", indent, node.name, ms, selfMs, node.calls); + out.add(line); + if (depth >= maxDepth) return; + List children = new ArrayList<>(node.children.values()); + children.sort(Comparator.comparingLong((Node n) -> n.totalNs).reversed()); + String childIndent = indent + "| "; + for (Node child : children) { + appendTreeAscii(out, child, childIndent, depth + 1, maxDepth, minMs); + } + } + + private static void emitIfDue(long nowNs) { + ProfilerSettings.OutputMode mode = getOutputMode(); + if (mode == ProfilerSettings.OutputMode.OFF) return; + if (RuntimeGate.isPanic()) return; + if (nowNs - lastEmitNs < DEFAULT_EMIT_INTERVAL_NS) return; + lastEmitNs = nowNs; + + if (mode == ProfilerSettings.OutputMode.LOG) { + emitToLog(); + } else if (mode == ProfilerSettings.OutputMode.CHAT) { + emitToChat(); + } + } + + private static ProfilerSettings.OutputMode getOutputMode() { + if (!isEnabled()) return ProfilerSettings.OutputMode.OFF; + return getOutputModeRaw(); + } + + private static ProfilerSettings.OutputMode getOutputModeRaw() { + return ProfilerSettings.getOutput2d(); + } + + private static void emitToLog() { + List lines = getDebugLines(); + if (lines.isEmpty()) return; + for (String line : lines) { + ProfilerLog.info("[UIProfiler] %s", line); + } + } + + private static void emitToChat() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null) return; + List lines = getDebugLines(4, 2, 0.05); + if (lines.isEmpty()) return; + for (String line : lines) { + mc.gui.hud.getChat().addClientSystemMessage(Component.literal("[UIProfiler] " + line)); + } + } + + private static long selfNs(Node node) { + if (node == null) return 0L; + long childrenNs = 0L; + for (Node child : node.children.values()) { + childrenNs += child.totalNs; + } + long self = node.totalNs - childrenNs; + return Math.max(0L, self); + } + + private static Node hottestChild(Node node) { + if (node == null || node.children.isEmpty()) return null; + Node best = null; + long bestNs = 0L; + for (Node child : node.children.values()) { + if (best == null || child.totalNs > bestNs) { + best = child; + bestNs = child.totalNs; + } + } + return best; + } + + private static String nodePath(Node node) { + if (node == null) return ""; + List parts = new ArrayList<>(); + Node cur = node; + while (cur != null) { + parts.add(cur.name); + cur = cur.parent; + } + StringBuilder sb = new StringBuilder(); + for (int i = parts.size() - 1; i >= 0; i--) { + if (sb.length() > 0) sb.append(" > "); + sb.append(parts.get(i)); + } + return sb.toString(); + } + + public static final class Section implements AutoCloseable { + private static final Section NOOP = new Section(false); + private final boolean active; + + private Section(boolean active) { + this.active = active; + } + + @Override + public void close() { + if (!active) return; + endSection(); + } + } + + private static final class Node { + final String name; + final Node parent; + final Map children = new HashMap<>(); + long totalNs; + long startNs; + int calls; + + private Node(String name, Node parent) { + this.name = name; + this.parent = parent; + } + + int countNodes() { + int count = 1; + for (Node child : children.values()) { + count += child.countNodes(); + } + return count; + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler3D.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler3D.java new file mode 100644 index 0000000..f556300 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevRenderProfiler3D.java @@ -0,0 +1,421 @@ +package dev.loki.lovisual.render.engine.profiler; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; +import dev.loki.lovisual.render.engine.rhi.RhiStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.resource.RenderResourceStatsSnapshot; +import dev.loki.lovisual.render.engine.rhi.uniform.UniformAllocatorStatsSnapshot; +import dev.loki.lovisual.render.engine.world.WorldRenderStatsSnapshot; +import dev.loki.lovisual.render.iris.IrisRuntimeSnapshot; +import dev.loki.lovisual.render.sodium.SodiumTerrainInteropStatsSnapshot; +import dev.loki.lovisual.render.sodium.SodiumVisibilityStatsSnapshot; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.*; + +public enum DevRenderProfiler3D { + ; + + private static final long NS_IN_MS = 1_000_000L; + private static final int DEFAULT_TOP_LIMIT = 6; + private static final int DEFAULT_TREE_DEPTH = 32; + private static final double DEFAULT_MIN_MS = 0.0; + private static final long DEFAULT_EMIT_INTERVAL_NS = 1_000_000_000L; + private static final ArrayDeque stack = new ArrayDeque<>(); + private static boolean active; + private static Node root; + private static Node snapshot; + private static long lastEmitNs; + + public static boolean isEnabled() { + return getOutputModeRaw() != ProfilerSettings.OutputMode.OFF; + } + + public static void beginFrame(String name) { + DevSamplingProfiler.onClientFrame(DevSamplingProfiler.Target.WORLD); + if (!isEnabled()) return; + active = true; + root = new Node(name, null); + stack.clear(); + stack.push(root); + root.startNs = System.nanoTime(); + } + + public static boolean beginFrameIfNeeded(String name) { + DevSamplingProfiler.onClientFrame(DevSamplingProfiler.Target.WORLD); + if (!isEnabled() || active) return false; + beginFrame(name); + return true; + } + + public static void endFrame() { + if (!isEnabled() || !active) return; + long now = System.nanoTime(); + while (stack.size() > 1) { + endSection(); + } + Node r = stack.peek(); + if (r != null) { + r.calls = Math.max(1, r.calls); + r.totalNs += now - r.startNs; + r.startNs = 0L; + snapshot = r; + DevTracyProfiler.plotWorldFrame(r.totalNs / (double) NS_IN_MS, r.countNodes()); + } + active = false; + emitIfDue(now); + } + + public static Section section(String name) { + if (!isEnabled() || !active) return Section.NOOP; + beginSection(name); + return new Section(true); + } + + private static void beginSection(String name) { + Node parent = stack.peek(); + if (parent == null) return; + Node child = parent.children.computeIfAbsent(name, n -> new Node(n, parent)); + child.calls += 1; + child.startNs = System.nanoTime(); + stack.push(child); + } + + private static void endSection() { + Node node = stack.poll(); + if (node == null) return; + long now = System.nanoTime(); + node.totalNs += now - node.startNs; + node.startNs = 0L; + } + + public static List getDebugLines() { + return getDebugLines(DEFAULT_TOP_LIMIT, DEFAULT_TREE_DEPTH, DEFAULT_MIN_MS); + } + + public static List getDebugLines(int topLimit, int depthLimit, double minMs) { + if (!isEnabled()) return List.of(); + Node snap = snapshot; + if (snap == null) return List.of(); + + List lines = new ArrayList<>(); + double totalMs = snap.totalNs / (double) NS_IN_MS; + lines.add(String.format("world prof: %.2f ms, nodes %d", totalMs, snap.countNodes())); + appendRhiCounters(lines); + appendCostCounters(lines); + + List flat = new ArrayList<>(); + collectFlat(snap, flat); + flat.sort(Comparator.comparingLong((Node n) -> n.totalNs).reversed()); + + int top = Math.min(topLimit, flat.size()); + if (top > 0) { + lines.add("world prof top:"); + for (int i = 0; i < top; i++) { + Node n = flat.get(i); + double ms = n.totalNs / (double) NS_IN_MS; + if (ms < minMs) break; + double selfMs = selfNs(n) / (double) NS_IN_MS; + Node hot = hottestChild(n); + if (hot != null) { + double hotMs = hot.totalNs / (double) NS_IN_MS; + lines.add(String.format( + " %d) %s %.2f ms (self %.2f, hot %s %.2f, %d)", + i + 1, + nodePath(n), + ms, + selfMs, + hot.name, + hotMs, + n.calls + )); + } else { + lines.add(String.format( + " %d) %s %.2f ms (self %.2f, %d)", + i + 1, + nodePath(n), + ms, + selfMs, + n.calls + )); + } + } + } + + lines.add("world prof tree:"); + appendTreeAscii(lines, snap, "", 0, depthLimit, minMs); + return lines; + } + + private static void appendCostCounters(List lines) { + List phase = DevRenderCostProfiler.debugLines("render cost phases", "phase", 6, 0.02); + if (!phase.isEmpty()) lines.addAll(phase); + List post = DevRenderCostProfiler.debugLines("world cost post", "post.pass", 8, 0.02); + if (!post.isEmpty()) lines.addAll(post); + List effects = DevRenderCostProfiler.debugLines("world cost effects", "world.effect", 8, 0.02); + if (!effects.isEmpty()) lines.addAll(effects); + List rhi = DevRenderCostProfiler.debugLines("world cost rhi draws", "rhi.draw", 8, 0.02); + if (!rhi.isEmpty()) lines.addAll(rhi); + } + + private static void appendRhiCounters(List lines) { + RhiStatsSnapshot rhi = CombatantRenderSystem.rhiStatsSnapshot(); + UniformAllocatorStatsSnapshot uniforms = CombatantRenderSystem.uniformStatsSnapshot(); + if (rhi.drawCalls() > 0 || rhi.renderPasses() > 0 || rhi.meshUploads() > 0 || rhi.fullscreenPasses() > 0 + || rhi.textureFastCopies() > 0 || rhi.textureShaderCopies() > 0) { + lines.add(String.format( + "rhi: draws %d, passes/switches %d/%d, meshUploads %d, fullscreen %d, copies fast/shader %d/%d, uploaded v/i %s/%s", + rhi.drawCalls(), + rhi.renderPasses(), + rhi.renderPassAttachmentSwitches(), + rhi.meshUploads(), + rhi.fullscreenPasses(), + rhi.textureFastCopies(), + rhi.textureShaderCopies(), + formatBytes(rhi.uploadedVertexBytes()), + formatBytes(rhi.uploadedIndexBytes()) + )); + } + WorldRenderStatsSnapshot world = CombatantRenderSystem.worldRenderStatsSnapshot(); + if (world.recordedCommands() > 0 || world.submittedCommands() > 0 || world.skippedEmptyCommands() > 0) { + lines.add(String.format( + "world3d: commands rec/sub/skip %d/%d/%d, vertices/indices %d/%d, fog %d, depth pre/main/none %d/%d/%d", + world.recordedCommands(), + world.submittedCommands(), + world.skippedEmptyCommands(), + world.submittedVertices(), + world.submittedIndices(), + world.fogBindings(), + world.depthPrePassBindings(), + world.depthMainBindings(), + world.depthDisabledBindings() + )); + } + + if (rhi.ringWraps() > 0 || rhi.ringStalls() > 0 || rhi.immediateFallbackUploads() > 0) { + lines.add(String.format( + "rhi upload: wraps %d, stalls %d, immediateFallback %d, arena reuse/retire %d/%d", + rhi.ringWraps(), + rhi.ringStalls(), + rhi.immediateFallbackUploads(), + rhi.dynamicArenaReuses(), + rhi.dynamicArenaRetires() + )); + } + if (rhi.legacyPathUses() > 0) { + lines.add("rhi legacy paths: " + rhi.legacyPathUses() + " " + rhi.legacyPathBreakdown()); + } + if (uniforms.writes() > 0 || uniforms.ringRotations() > 0 || uniforms.staleReadMisses() > 0) { + lines.add(String.format( + "uniforms: writes %d, bytes %s, activeStreams %d, rotations %d, grows %d, staleMiss %d", + uniforms.writes(), + formatBytes(uniforms.uploadedBytes()), + uniforms.activeStreams(), + uniforms.ringRotations(), + uniforms.ringGrows(), + uniforms.staleReadMisses() + )); + } + RenderResourceStatsSnapshot resources = CombatantRenderSystem.resourceStatsSnapshot(); + if (resources.persistentFramebuffers() > 0 || resources.temporaryFramebuffers() > 0 + || resources.retirementBacklog() > 0 || resources.leakedResources() > 0) { + lines.add(String.format( + "resources: fb persistent/temp %d/%d, create/resize %d/%d, borrow/release %d/%d, retire backlog/leaks %d/%d", + resources.persistentFramebuffers(), + resources.temporaryFramebuffers(), + resources.framebufferCreates(), + resources.framebufferResizes(), + resources.framebufferBorrows(), + resources.framebufferReleases(), + resources.retirementBacklog(), + resources.leakedResources() + )); + } + + IrisRuntimeSnapshot iris = CombatantRenderSystem.irisSnapshot(); + if (iris.modLoaded() || iris.shaderpackInUse()) { + lines.add(iris.shortLine()); + } + + var immediatelyFast = CombatantRenderSystem.immediatelyFastSnapshot(); + if (immediatelyFast.modLoaded()) { + lines.add(immediatelyFast.shortLine()); + } + + SodiumVisibilityStatsSnapshot sodiumVisibility = CombatantRenderSystem.sodiumVisibilityStatsSnapshot(); + if (sodiumVisibility.queries() > 0 || sodiumVisibility.errors() > 0) { + lines.add(String.format( + "sodium vis: queries %d, accepts/rejects %d/%d, bypass %d (unavail %d, optout %d), errors %d", + sodiumVisibility.queries(), + sodiumVisibility.accepts(), + sodiumVisibility.rejects(), + sodiumVisibility.bypasses(), + sodiumVisibility.unavailableBypasses(), + sodiumVisibility.optOutBypasses(), + sodiumVisibility.errors() + )); + } + + SodiumTerrainInteropStatsSnapshot sodiumTerrain = CombatantRenderSystem.sodiumTerrainStatsSnapshot(); + if (sodiumTerrain.terrainUpdatesScheduled() > 0 + || sodiumTerrain.rebuildsScheduled() > 0 + || sodiumTerrain.interopErrors() > 0) { + lines.add(String.format( + "sodium terrain: updates %d, rebuilds %d, errors %d", + sodiumTerrain.terrainUpdatesScheduled(), + sodiumTerrain.rebuildsScheduled(), + sodiumTerrain.interopErrors() + )); + } + } + + private static String formatBytes(long bytes) { + if (bytes < 1024L) return bytes + " B"; + double kb = bytes / 1024.0; + if (kb < 1024.0) return String.format(Locale.ROOT, "%.1f KiB", kb); + return String.format(Locale.ROOT, "%.2f MiB", kb / 1024.0); + } + + private static void collectFlat(Node node, List out) { + for (Node child : node.children.values()) { + out.add(child); + collectFlat(child, out); + } + } + + private static void appendTreeAscii(List out, Node node, String indent, int depth, int maxDepth, double minMs) { + if (node == null) return; + double ms = node.totalNs / (double) NS_IN_MS; + if (depth > 0 && ms < minMs) return; + double selfMs = selfNs(node) / (double) NS_IN_MS; + String line = depth == 0 + ? String.format("%s %.2f ms (self %.2f, %d)", node.name, ms, selfMs, node.calls) + : String.format("%s|-- %s %.2f ms (self %.2f, %d)", indent, node.name, ms, selfMs, node.calls); + out.add(line); + if (depth >= maxDepth) return; + List children = new ArrayList<>(node.children.values()); + children.sort(Comparator.comparingLong((Node n) -> n.totalNs).reversed()); + String childIndent = indent + "| "; + for (Node child : children) { + appendTreeAscii(out, child, childIndent, depth + 1, maxDepth, minMs); + } + } + + private static void emitIfDue(long nowNs) { + ProfilerSettings.OutputMode mode = getOutputMode(); + if (mode == ProfilerSettings.OutputMode.OFF) return; + if (RuntimeGate.isPanic()) return; + if (nowNs - lastEmitNs < DEFAULT_EMIT_INTERVAL_NS) return; + lastEmitNs = nowNs; + + if (mode == ProfilerSettings.OutputMode.LOG) { + emitToLog(); + } else if (mode == ProfilerSettings.OutputMode.CHAT) { + emitToChat(); + } + } + + private static ProfilerSettings.OutputMode getOutputMode() { + if (!isEnabled()) return ProfilerSettings.OutputMode.OFF; + return getOutputModeRaw(); + } + + private static ProfilerSettings.OutputMode getOutputModeRaw() { + return ProfilerSettings.getOutput3d(); + } + + private static void emitToLog() { + List lines = getDebugLines(); + if (lines.isEmpty()) return; + for (String line : lines) { + ProfilerLog.info("[WorldProfiler] %s", line); + } + } + + private static void emitToChat() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null) return; + List lines = getDebugLines(4, 2, 0.05); + if (lines.isEmpty()) return; + for (String line : lines) { + mc.gui.hud.getChat().addClientSystemMessage(Component.literal("[WorldProfiler] " + line)); + } + } + + private static long selfNs(Node node) { + if (node == null) return 0L; + long childrenNs = 0L; + for (Node child : node.children.values()) { + childrenNs += child.totalNs; + } + long self = node.totalNs - childrenNs; + return Math.max(0L, self); + } + + private static Node hottestChild(Node node) { + if (node == null || node.children.isEmpty()) return null; + Node best = null; + long bestNs = 0L; + for (Node child : node.children.values()) { + if (best == null || child.totalNs > bestNs) { + best = child; + bestNs = child.totalNs; + } + } + return best; + } + + private static String nodePath(Node node) { + if (node == null) return ""; + List parts = new ArrayList<>(); + Node cur = node; + while (cur != null) { + parts.add(cur.name); + cur = cur.parent; + } + StringBuilder sb = new StringBuilder(); + for (int i = parts.size() - 1; i >= 0; i--) { + if (sb.length() > 0) sb.append(" > "); + sb.append(parts.get(i)); + } + return sb.toString(); + } + + public static final class Section implements AutoCloseable { + private static final Section NOOP = new Section(false); + private final boolean active; + + private Section(boolean active) { + this.active = active; + } + + @Override + public void close() { + if (!active) return; + endSection(); + } + } + + private static final class Node { + final String name; + final Node parent; + final Map children = new HashMap<>(); + long totalNs; + long startNs; + int calls; + + private Node(String name, Node parent) { + this.name = name; + this.parent = parent; + } + + int countNodes() { + int count = 1; + for (Node child : children.values()) { + count += child.countNodes(); + } + return count; + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevSamplingProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevSamplingProfiler.java new file mode 100644 index 0000000..da6c4cc --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevSamplingProfiler.java @@ -0,0 +1,447 @@ +package dev.loki.lovisual.render.engine.profiler; + +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public enum DevSamplingProfiler { + ; + + private static final long SAMPLE_INTERVAL_NS = 10_000_000L; // 10 ms + private static final long EMIT_INTERVAL_NS = 1_000_000_000L; // 1 s + private static final int TOP_LIMIT = 8; + private static final int TOP_LIMIT_NON_GL = 4; + private static final int TOP_LIMIT_GL_WAIT = 3; + private static final int MAX_STACK_FRAMES = 4; + private static final int MAX_ENTRIES = 256; + + private static final Object LOCK = new Object(); + private static final Map COUNTS = new HashMap<>(); + private static int idleSamples; + + private static volatile Thread targetThread; + private static volatile Thread samplerThread; + private static volatile boolean running; + private static volatile long lastEmitNs; + private static volatile List pendingChatLines; + private static volatile Target lastSource = Target.WORLD; + + public static void onClientFrame(Target source) { + if (!isEnabled()) { + DevProfilerPhase.clearCurrent(); + stopIfRunning(); + return; + } + if (RuntimeGate.isPanic()) return; + lastSource = source; + flushPendingChat(); + if (!matchesTarget(source)) return; + targetThread = Thread.currentThread(); + ensureRunning(); + } + + private static boolean isEnabled() { + return getOutputModeRaw() != ProfilerSettings.OutputMode.OFF; + } + + public static boolean isActive() { + return getOutputModeRaw() != ProfilerSettings.OutputMode.OFF; + } + + private static void ensureRunning() { + if (running) return; + running = true; + samplerThread = new Thread(DevSamplingProfiler::runLoop, "Combatant-DevSamplingProfiler"); + samplerThread.setDaemon(true); + samplerThread.start(); + } + + private static void stopIfRunning() { + if (!running) return; + running = false; + Thread t = samplerThread; + samplerThread = null; + if (t != null) t.interrupt(); + synchronized (LOCK) { + COUNTS.clear(); + idleSamples = 0; + } + DevGlSyncTracker.reset(); + pendingChatLines = null; + } + + private static void runLoop() { + long lastSampleNs = 0L; + while (running) { + if (!isEnabled()) { + running = false; + synchronized (LOCK) { + COUNTS.clear(); + idleSamples = 0; + } + DevGlSyncTracker.reset(); + pendingChatLines = null; + break; + } + if (RuntimeGate.isPanic()) { + sleepMs(20); + continue; + } + Thread t = targetThread; + if (t == null) { + sleepMs(10); + continue; + } + long now = System.nanoTime(); + if (now - lastSampleNs >= SAMPLE_INTERVAL_NS) { + if (matchesTarget(lastSource)) { + sample(t); + } + lastSampleNs = now; + } + if (now - lastEmitNs >= EMIT_INTERVAL_NS) { + emit(now); + } + sleepMs(2); + } + } + + private static void sample(Thread t) { + StackTraceElement[] stack = t.getStackTrace(); + if (stack == null || stack.length == 0) return; + if (isIdleStack(stack)) { + synchronized (LOCK) { + idleSamples += 1; + } + return; + } + String key = buildKey(stack); + if (key == null || key.isEmpty()) return; + synchronized (LOCK) { + COUNTS.merge(key, 1, Integer::sum); + if (COUNTS.size() > MAX_ENTRIES) { + trimToLimit(); + } + } + } + + private static void emit(long now) { + ProfilerSettings.OutputMode mode = getOutputModeRaw(); + if (mode == ProfilerSettings.OutputMode.OFF) return; + List lines = buildOutputLines(); + if (lines.isEmpty()) return; + lastEmitNs = now; + + if (mode == ProfilerSettings.OutputMode.LOG) { + for (String line : lines) { + ProfilerLog.info("[SampleProfiler] %s", line); + } + } else if (mode == ProfilerSettings.OutputMode.CHAT) { + pendingChatLines = lines; + } + + synchronized (LOCK) { + COUNTS.clear(); + idleSamples = 0; + } + } + + private static List buildOutputLines() { + List> entries; + int total; + int idle; + synchronized (LOCK) { + if (COUNTS.isEmpty() && idleSamples == 0) return List.of(); + entries = new ArrayList<>(COUNTS.entrySet()); + int sum = 0; + for (Integer count : COUNTS.values()) { + if (count != null) { + sum += count; + } + } + total = sum; + idle = idleSamples; + } + entries.sort((a, b) -> Integer.compare(b.getValue(), a.getValue())); + + List lines = new ArrayList<>(); + int all = total + idle; + double idlePct = all == 0 ? 0.0 : (idle * 100.0) / all; + lines.add(String.format("sample prof: %d samples, idle %d (%.1f%%), entries %d", all, idle, idlePct, entries.size())); + + int glWait = 0; + int glUpload = 0; + int glDraw = 0; + int glState = 0; + int glWaitClient = 0; + int glWaitSwap = 0; + int glWaitFinish = 0; + int glWaitSync = 0; + int glWaitOther = 0; + + List> nonGl = new ArrayList<>(); + List> glWaitList = new ArrayList<>(); + for (Map.Entry e : entries) { + String k = e.getKey(); + int c = e.getValue(); + if (k.startsWith("GL WAIT:")) { + glWait += c; + glWaitList.add(e); + if (containsAny(k, "glClientWaitSync", "nglClientWaitSync")) { + glWaitClient += c; + } else if (containsAny(k, "glfwSwapBuffers")) { + glWaitSwap += c; + } else if (containsAny(k, "glFinish", "nglFinish")) { + glWaitFinish += c; + } else if (containsAny(k, "glWaitSync", "nglWaitSync")) { + glWaitSync += c; + } else { + glWaitOther += c; + } + } else if (k.startsWith("GL UPLOAD:")) { + glUpload += c; + } else if (k.startsWith("GL DRAW:")) { + glDraw += c; + } else if (k.startsWith("GL STATE:")) { + glState += c; + } else { + nonGl.add(e); + } + } + int glTotal = glWait + glUpload + glDraw + glState; + if (glTotal > 0) { + double waitPct = all == 0 ? 0.0 : (glWait * 100.0) / all; + double uploadPct = all == 0 ? 0.0 : (glUpload * 100.0) / all; + double drawPct = all == 0 ? 0.0 : (glDraw * 100.0) / all; + double statePct = all == 0 ? 0.0 : (glState * 100.0) / all; + lines.add(String.format( + "gl samples: wait %d (%.1f%%), upload %d (%.1f%%), draw %d (%.1f%%), state %d (%.1f%%)", + glWait, waitPct, glUpload, uploadPct, glDraw, drawPct, glState, statePct + )); + if (glWait > 0) { + lines.add(String.format( + "gl wait breakdown: client %d, swap %d, finish %d, waitsync %d, other %d", + glWaitClient, glWaitSwap, glWaitFinish, glWaitSync, glWaitOther + )); + } + } + + List waitPhaseLines = DevGlSyncTracker.drainLines(); + if (!waitPhaseLines.isEmpty()) { + lines.addAll(waitPhaseLines); + } + lines.add("sample prof top:"); + + int top = Math.min(TOP_LIMIT, entries.size()); + for (int i = 0; i < top; i++) { + Map.Entry e = entries.get(i); + double pct = all == 0 ? 0.0 : (e.getValue() * 100.0) / all; + lines.add(String.format(" %d) %s (%d, %.1f%%)", i + 1, e.getKey(), e.getValue(), pct)); + } + + if (!nonGl.isEmpty()) { + int topNon = Math.min(TOP_LIMIT_NON_GL, nonGl.size()); + lines.add("sample prof top non-gl:"); + for (int i = 0; i < topNon; i++) { + Map.Entry e = nonGl.get(i); + double pct = all == 0 ? 0.0 : (e.getValue() * 100.0) / all; + lines.add(String.format(" %d) %s (%d, %.1f%%)", i + 1, e.getKey(), e.getValue(), pct)); + } + } + + if (!glWaitList.isEmpty()) { + int topWait = Math.min(TOP_LIMIT_GL_WAIT, glWaitList.size()); + lines.add("sample prof top gl wait:"); + for (int i = 0; i < topWait; i++) { + Map.Entry e = glWaitList.get(i); + double pct = all == 0 ? 0.0 : (e.getValue() * 100.0) / all; + lines.add(String.format(" %d) %s (%d, %.1f%%)", i + 1, e.getKey(), e.getValue(), pct)); + } + } + return lines; + } + + private static void flushPendingChat() { + List lines = pendingChatLines; + if (lines == null || lines.isEmpty()) return; + pendingChatLines = null; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null) return; + for (String line : lines) { + mc.gui.hud.getChat().addClientSystemMessage(Component.literal("[SampleProfiler] " + line)); + } + } + + private static String buildKey(StackTraceElement[] stack) { + StringBuilder sb = new StringBuilder(); + String glCat = glCategory(stack); + boolean hasPrefix = false; + if (glCat != null) { + sb.append("GL ").append(glCat).append(": "); + hasPrefix = true; + } + int added = 0; + for (StackTraceElement e : stack) { + if (added >= MAX_STACK_FRAMES) break; + if (isNoise(e)) continue; + if (added > 0 || (sb.length() > 0 && !hasPrefix)) sb.append(" <= "); + sb.append(formatElement(e)); + added++; + } + return added == 0 ? null : sb.toString(); + } + + private static boolean isNoise(StackTraceElement e) { + String cls = e.getClassName(); + return cls.startsWith("java.lang.Thread") + || cls.startsWith("java.util.concurrent") + || cls.startsWith("jdk.internal") + || cls.startsWith("sun.") + || cls.startsWith("dev.loki.lovisual.render.engine.profiler.DevSamplingProfiler"); + } + + private static String glCategory(StackTraceElement[] stack) { + for (StackTraceElement e : stack) { + String cls = e.getClassName(); + String method = e.getMethodName(); + if (!isGlFrame(cls)) continue; + String cat = glCategoryForMethod(method); + if (cat != null) return cat; + } + return null; + } + + private static boolean isGlFrame(String cls) { + if (cls == null) return false; + return cls.startsWith("org.lwjgl.opengl.") + || cls.startsWith("org.lwjgl.glfw.") + || cls.startsWith("com.mojang.blaze3d.platform.") + || cls.startsWith("com.mojang.blaze3d.systems."); + } + + private static String glCategoryForMethod(String method) { + if (method == null) return null; + if (method.equals("glfwSwapBuffers") + || method.equals("glClientWaitSync") + || method.equals("glWaitSync") + || method.equals("glFinish")) { + return "WAIT"; + } + if (method.startsWith("glDraw") || method.startsWith("glMultiDraw")) { + return "DRAW"; + } + if (method.contains("BufferSubData") + || method.contains("BufferData") + || method.contains("TexImage") + || method.contains("TexSubImage") + || method.contains("CompressedTex") + || method.contains("FlushMapped") + || method.contains("MapBuffer") + || method.contains("MapBufferRange") + || method.contains("UnmapBuffer")) { + return "UPLOAD"; + } + if (method.startsWith("glUseProgram") + || method.startsWith("glBind") + || method.startsWith("glTexParameter") + || method.startsWith("glBlendFunc") + || method.startsWith("glBlendEquation") + || method.startsWith("glEnable") + || method.startsWith("glDisable") + || method.startsWith("glDepthFunc") + || method.startsWith("glDepthMask") + || method.startsWith("glColorMask") + || method.startsWith("glCullFace") + || method.startsWith("glFrontFace") + || method.startsWith("glViewport") + || method.startsWith("glScissor") + || method.startsWith("glPolygonMode") + || method.startsWith("glActiveTexture") + || method.startsWith("glUniform") + || method.startsWith("glVertexAttrib")) { + return "STATE"; + } + return null; + } + + private static boolean containsAny(String text, String... needles) { + if (text == null) return false; + for (String n : needles) { + if (n != null && !n.isEmpty() && text.contains(n)) return true; + } + return false; + } + + private static boolean isIdleStack(StackTraceElement[] stack) { + boolean hasWait = false; + boolean hasLimit = false; + for (StackTraceElement e : stack) { + String cls = e.getClassName(); + String method = e.getMethodName(); + if ("org.lwjgl.glfw.GLFW".equals(cls) && "glfwWaitEventsTimeout".equals(method)) { + hasWait = true; + } + if ("com.mojang.blaze3d.systems.RenderSystem".equals(cls) && "limitDisplayFPS".equals(method)) { + hasLimit = true; + } + if (hasWait || hasLimit) { + return true; + } + } + return false; + } + + private static String formatElement(StackTraceElement e) { + String file = e.getFileName() == null ? "Unknown" : e.getFileName(); + int line = e.getLineNumber(); + return e.getClassName() + "." + e.getMethodName() + "(" + file + ":" + line + ")"; + } + + private static boolean matchesTarget(Target source) { + if (source == null) return true; + Target target = getTargetRaw(); + return target == Target.ALL || target == source; + } + + private static Target getTargetRaw() { + ProfilerSettings.SampleTarget target = ProfilerSettings.getSampleTarget(); + if (target == null) return Target.ALL; + return switch (target) { + case D2 -> Target.UI; + case D3 -> Target.WORLD; + case ALL -> Target.ALL; + }; + } + + private static void trimToLimit() { + if (COUNTS.size() <= MAX_ENTRIES) return; + List> entries = new ArrayList<>(COUNTS.entrySet()); + entries.sort((a, b) -> Integer.compare(b.getValue(), a.getValue())); + for (int i = MAX_ENTRIES; i < entries.size(); i++) { + COUNTS.remove(entries.get(i).getKey()); + } + } + + private static ProfilerSettings.OutputMode getOutputModeRaw() { + return ProfilerSettings.getSampleOutput(); + } + + private static void sleepMs(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ignored) { + // ignore + } + } + + public enum Target { + ALL, + UI, + WORLD + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyGpuProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyGpuProfiler.java new file mode 100644 index 0000000..7c344a8 --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyGpuProfiler.java @@ -0,0 +1,150 @@ +package dev.loki.lovisual.render.engine.profiler; + +import com.mojang.jtracy.GpuApi; +import com.mojang.jtracy.GpuContext; +import com.mojang.jtracy.TracyClient; +import org.lwjgl.opengl.GL; +import org.lwjgl.opengl.GL15C; +import org.lwjgl.opengl.GL33C; +import org.lwjgl.opengl.GLCapabilities; +import dev.loki.lovisual.render.engine.guard.LegacyRenderPath; +import dev.loki.lovisual.render.engine.guard.RenderBoundaryExempt; + +import java.util.ArrayDeque; + +@RenderBoundaryExempt(value = LegacyRenderPath.DIRECT_GL_OUTSIDE_BACKEND, reason = "Profiler-only timer query path; not part of renderer submission architecture") +public enum DevTracyGpuProfiler { + ; + + private static final Scope NOOP_SCOPE = new Scope(false); + private static final Object LOCK = new Object(); + private static final ArrayDeque PENDING = new ArrayDeque<>(); + private static final ArrayDeque FREE_GL_QUERIES = new ArrayDeque<>(); + + private static volatile boolean initAttempted; + private static volatile boolean available; + private static volatile GpuContext context; + private static int nextQueryId = 1; + + public static boolean isEnabled() { + return DevTracyProfiler.isEnabled() && isAvailable(); + } + + public static Scope beginZone(String name) { + if (!isEnabled() || name == null || name.isBlank()) { + return NOOP_SCOPE; + } + int beginId = allocateQueryId(); + int beginQuery = allocateGlQuery(); + context.beginZone(beginId, name, "", "", 0); + GL33C.glQueryCounter(beginQuery, GL33C.GL_TIMESTAMP); + enqueue(beginId, beginQuery); + return new Scope(true); + } + + public static void onFrameEnd() { + if (!isEnabled()) { + return; + } + pump(); + } + + private static boolean isAvailable() { + ensureContext(); + return available; + } + + private static void ensureContext() { + if (initAttempted) { + return; + } + synchronized (LOCK) { + if (initAttempted) { + return; + } + initAttempted = true; + try { + GLCapabilities caps = GL.getCapabilities(); + boolean timerQuery = caps.OpenGL33 || caps.GL_ARB_timer_query; + if (!timerQuery) { + available = false; + ProfilerLog.warn("Tracy GPU unavailable: timer queries are not supported by this OpenGL context"); + return; + } + long gpuTimestamp = GL33C.glGetInteger64(GL33C.GL_TIMESTAMP); + context = TracyClient.createGpuContext(GpuApi.OPENGL, gpuTimestamp, 1.0f); + context.setName("main"); + available = true; + ProfilerLog.info("Tracy GPU context initialized"); + } catch (Throwable t) { + available = false; + ProfilerLog.warn("Tracy GPU unavailable: %s", t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + } + + private static void pump() { + synchronized (LOCK) { + while (!PENDING.isEmpty()) { + QueryEvent event = PENDING.peekFirst(); + if (GL15C.glGetQueryObjecti(event.glQuery, GL15C.GL_QUERY_RESULT_AVAILABLE) == 0) { + break; + } + long timestamp = GL33C.glGetQueryObjecti64(event.glQuery, GL15C.GL_QUERY_RESULT); + context.submitQueryTimestamp(event.queryId, timestamp); + PENDING.removeFirst(); + FREE_GL_QUERIES.addLast(event.glQuery); + } + } + } + + private static void enqueue(int queryId, int glQuery) { + synchronized (LOCK) { + PENDING.addLast(new QueryEvent(queryId, glQuery)); + } + } + + private static int allocateQueryId() { + synchronized (LOCK) { + int id = nextQueryId++; + if (id <= 0) { + nextQueryId = 1; + id = nextQueryId++; + } + return id; + } + } + + private static int allocateGlQuery() { + synchronized (LOCK) { + Integer existing = FREE_GL_QUERIES.pollFirst(); + if (existing != null) { + return existing; + } + } + return GL15C.glGenQueries(); + } + + private record QueryEvent(int queryId, int glQuery) { + } + + public static final class Scope implements AutoCloseable { + private final boolean active; + + private Scope(boolean active) { + this.active = active; + } + + @Override + public void close() { + if (!active || !isEnabled()) { + return; + } + int endId = allocateQueryId(); + int endQuery = allocateGlQuery(); + context.endZone(endId); + GL33C.glQueryCounter(endQuery, GL33C.GL_TIMESTAMP); + enqueue(endId, endQuery); + } + } +} diff --git a/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyProfiler.java b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyProfiler.java new file mode 100644 index 0000000..de6dd8b --- /dev/null +++ b/combatant-client-26.2/src/dev/java/dev/loki/lovisual/render/engine/profiler/DevTracyProfiler.java @@ -0,0 +1,230 @@ +package dev.loki.lovisual.render.engine.profiler; + +import com.mojang.jtracy.Plot; +import com.mojang.jtracy.TracyClient; +import com.mojang.jtracy.Zone; + +public enum DevTracyProfiler { + ; + + private static final Scope NOOP_SCOPE = new Scope(null); + private static final ThreadLocal THREAD_NAMED = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + private static volatile boolean loadAttempted; + private static volatile boolean available; + private static volatile Plot uiFrameMsPlot; + private static volatile Plot uiNodeCountPlot; + private static volatile Plot worldFrameMsPlot; + private static volatile Plot worldNodeCountPlot; + private static volatile Plot glWaitMsPlot; + private static volatile Plot glWaitCallsPlot; + private static volatile Plot uiBatchDrawsPlot; + private static volatile Plot uiBatchVerticesPlot; + + public static boolean isEnabled() { + return ProfilerSettings.isTracyEnabled() && isAvailable(); + } + + public static boolean isAvailable() { + ensureLoaded(); + return available; + } + + public static synchronized boolean setEnabled(boolean enabled) { + ProfilerSettings.setTracyEnabled(enabled); + if (!enabled) { + ProfilerLog.info("Tracy disabled"); + return false; + } + boolean active = isAvailable(); + if (active) { + ProfilerLog.info("Tracy enabled; data is streamed to the external Tracy viewer, not latest.log"); + TracyClient.message("Combatant Tracy enabled"); + } + return active; + } + + public static Scope beginZone(String name) { + if (!isEnabled() || name == null || name.isBlank()) { + return NOOP_SCOPE; + } + ensureThreadNamed(); + return new Scope(TracyClient.beginZone(name, false)); + } + + public static void markFrame() { + if (!isEnabled()) return; + ensureThreadNamed(); + TracyClient.markFrame(); + } + + public static boolean shouldTraceCurrentThread() { + String threadName = Thread.currentThread().getName(); + if (threadName == null || threadName.isBlank()) { + return false; + } + return "Render thread".equals(threadName) + || threadName.startsWith("Render thread") + || "main".equals(threadName) + || "Main thread".equals(threadName); + } + + public static void plotUiFrame(double ms, int nodes) { + if (!isEnabled()) return; + plotUiFrameMs().setValue(ms); + plotUiNodeCount().setValue(nodes); + } + + public static void plotWorldFrame(double ms, int nodes) { + if (!isEnabled()) return; + plotWorldFrameMs().setValue(ms); + plotWorldNodeCount().setValue(nodes); + } + + public static void plotGlWait(double ms, int calls) { + if (!isEnabled()) return; + plotGlWaitMs().setValue(ms); + plotGlWaitCalls().setValue(calls); + } + + public static void plotUiBatch(int draws, int vertices) { + if (!isEnabled()) return; + plotUiBatchDraws().setValue(draws); + plotUiBatchVertices().setValue(vertices); + } + + private static void ensureLoaded() { + if (loadAttempted) { + return; + } + synchronized (DevTracyProfiler.class) { + if (loadAttempted) { + return; + } + loadAttempted = true; + try { + TracyClient.load(); + TracyClient.reportAppInfo("Combatant render profiler"); + available = TracyClient.isAvailable(); + if (available) { + ProfilerLog.info("Tracy client loaded"); + } + } catch (Throwable t) { + available = false; + ProfilerLog.warn("Tracy unavailable: %s", t.getClass().getSimpleName() + ": " + t.getMessage()); + } + } + } + + private static void ensureThreadNamed() { + if (Boolean.TRUE.equals(THREAD_NAMED.get())) { + return; + } + TracyClient.setThreadName(Thread.currentThread().getName(), 0); + THREAD_NAMED.set(Boolean.TRUE); + } + + private static Plot plotUiFrameMs() { + Plot plot = uiFrameMsPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (uiFrameMsPlot == null) { + uiFrameMsPlot = TracyClient.createPlot("render.ui.ms"); + } + return uiFrameMsPlot; + } + } + + private static Plot plotUiNodeCount() { + Plot plot = uiNodeCountPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (uiNodeCountPlot == null) { + uiNodeCountPlot = TracyClient.createPlot("render.ui.nodes"); + } + return uiNodeCountPlot; + } + } + + private static Plot plotWorldFrameMs() { + Plot plot = worldFrameMsPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (worldFrameMsPlot == null) { + worldFrameMsPlot = TracyClient.createPlot("render.world.ms"); + } + return worldFrameMsPlot; + } + } + + private static Plot plotWorldNodeCount() { + Plot plot = worldNodeCountPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (worldNodeCountPlot == null) { + worldNodeCountPlot = TracyClient.createPlot("render.world.nodes"); + } + return worldNodeCountPlot; + } + } + + private static Plot plotGlWaitMs() { + Plot plot = glWaitMsPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (glWaitMsPlot == null) { + glWaitMsPlot = TracyClient.createPlot("render.gl_wait.ms"); + } + return glWaitMsPlot; + } + } + + private static Plot plotGlWaitCalls() { + Plot plot = glWaitCallsPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (glWaitCallsPlot == null) { + glWaitCallsPlot = TracyClient.createPlot("render.gl_wait.calls"); + } + return glWaitCallsPlot; + } + } + + private static Plot plotUiBatchDraws() { + Plot plot = uiBatchDrawsPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (uiBatchDrawsPlot == null) { + uiBatchDrawsPlot = TracyClient.createPlot("render.ui_batch.draws"); + } + return uiBatchDrawsPlot; + } + } + + private static Plot plotUiBatchVertices() { + Plot plot = uiBatchVerticesPlot; + if (plot != null) return plot; + synchronized (DevTracyProfiler.class) { + if (uiBatchVerticesPlot == null) { + uiBatchVerticesPlot = TracyClient.createPlot("render.ui_batch.vertices"); + } + return uiBatchVerticesPlot; + } + } + + public static final class Scope implements AutoCloseable { + private final Zone zone; + + private Scope(Zone zone) { + this.zone = zone; + } + + @Override + public void close() { + if (zone != null) { + zone.close(); + } + } + } +} diff --git a/combatant-client-26.2/src/dev/resources/lovisual-dev.mixins.json b/combatant-client-26.2/src/dev/resources/lovisual-dev.mixins.json new file mode 100644 index 0000000..1b393b1 --- /dev/null +++ b/combatant-client-26.2/src/dev/resources/lovisual-dev.mixins.json @@ -0,0 +1,16 @@ +{ + "required": true, + "plugin": "dev.loki.lovisual.mixins.LoVisualMixinPlugin", + "minVersion": "0.8", + "package": "dev.loki.lovisual.mixins", + "compatibilityLevel": "JAVA_21", + "client": [ + "ProfilersMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java new file mode 100644 index 0000000..e9b159b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java @@ -0,0 +1,525 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual; + +import dev.loki.lovisual.events.impl.GameTickEvent; +import dev.loki.lovisual.runtime.*; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements; +import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import dev.loki.lovisual.addon.AddonManager; +import dev.loki.lovisual.addon.AddonRenderPipelineManager; +import dev.loki.lovisual.api.v0.render.LoVisualRenderStage; +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.events.Events; +import dev.loki.lovisual.features.account.AccountConfig; +import dev.loki.lovisual.features.command.CommandManager; +import dev.loki.lovisual.features.command.VanillaChatRuntimeCleanup; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.settings.I18nPreflightContributors; +import dev.loki.lovisual.features.gui.hud.HudElements; +import dev.loki.lovisual.features.gui.hud.HudRenderSpace; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementBootstrap; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.impl.SwapTooltip; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.module.ModuleAutoLoader; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.features.module.WorldPhase; +import dev.loki.lovisual.mixininterface.IGuiGraphics; +import dev.loki.lovisual.render.effects.NetherPortalRiftPass; +import dev.loki.lovisual.render.effects.SleepOverlayPass; +import dev.loki.lovisual.render.engine.CombatantRenderEngineBootstrap; +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; +import dev.loki.lovisual.render.engine.core.RenderPhase; +import dev.loki.lovisual.render.engine.core.RenderPhaseScope; +import dev.loki.lovisual.render.engine.core.ViewportContext; +import dev.loki.lovisual.render.engine.postprocess.PostProcessManager; +import dev.loki.lovisual.render.engine.profiler.ProfilerPhase; +import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D; +import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer; +import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmContributors; +import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmManager; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.render.helpers.TickDelta; +import dev.loki.lovisual.util.aiming.RotationManager; +import dev.loki.lovisual.util.block.bed.SelfBedTracker; +import dev.loki.lovisual.util.click.AttackPressing; +import dev.loki.lovisual.util.combat.AntiBotTracker; +import dev.loki.lovisual.util.combat.protocol.CombatProtocolHeuristics; +import dev.loki.lovisual.util.combat.SprintController; +import dev.loki.lovisual.util.combat.VulcanReachController; +import dev.loki.lovisual.util.entity.simulation.BoatSimulationCache; +import dev.loki.lovisual.util.media.MediaSessionService; +import dev.loki.lovisual.util.network.BacktrackController; +import dev.loki.lovisual.util.network.BlinkManager; +import dev.loki.lovisual.util.proxy.ProxyBackend; +import dev.loki.lovisual.util.network.FakeLagController; +import dev.loki.lovisual.util.player.inventory.InventorySwap; +import dev.loki.lovisual.util.player.simulation.PlayerSimulationCache; +import dev.loki.lovisual.util.pvp.PvpTargetTracker; +import dev.loki.lovisual.util.pvp.PvpTracker; +import dev.loki.lovisual.util.target.TargetManager; +import dev.loki.lovisual.util.time.TimerController; + +public class LoVisual implements ClientModInitializer { + + private static void registerRuntimeLifecycle() { + AddonManager.registerRuntimeLifecycle(); + ClientRuntime.setCallbackCounter(Events.BUS::listenerCount); + ClientRuntime.setModuleCounter(ModuleManager::registeredCount); + + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "modules"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + ModuleManager.shutdownForRuntime(context); + } + }); + ClientRuntime.registerResumeParticipant(new RuntimeResumeParticipant() { + @Override + public String id() { + return "modules"; + } + + @Override + public void resume(String reason) { + ModuleManager.restoreAfterSoftPanic(reason); + } + }); + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "clickgui"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + ClickGuiRenderer.shutdownForRuntime(); + } + }); + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "vanilla_chat"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + VanillaChatRuntimeCleanup.clearVanillaChatCache(); + context.increment("vanillaChatCleared", 1); + } + }); + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "hud"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + if (context.jarReplacement()) { + DraggableHudElementRegistry.clearRuntimeReferences(); + StaticHudElementRegistry.clearRuntimeReferences(); + } + } + }); + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "render"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + if (context.jarReplacement()) { + PostProcessManager.shutdownForRuntime(); + CombatantRenderSystem.shutdown(); + } + } + }); + } + + private static void renderWorldPhase(WorldPhase phase, + com.mojang.blaze3d.vertex.PoseStack matrices, + net.minecraft.client.renderer.SubmitNodeCollector consumers) { + if (!RuntimeGate.canRunRender()) return; + + ModuleManager.renderWorld( + phase, + matrices, + consumers, + TickDelta.tickProgress(false) + ); + } + + private static void refreshHudFrameTiming(DeltaTracker tickCounter) { + CombatantRenderSystem.updateFrameTiming( + TickDelta.tickProgress(tickCounter, false), + TickDelta.frameDeltaTicks(tickCounter), + TickDelta.fixedDeltaTicks(tickCounter) + ); + } + + private static void renderHudPhase(HudPhase phase, GuiGraphicsExtractor ctx, DeltaTracker tickCounter) { + if (!RuntimeGate.canRunHud()) return; + float tickProgress = TickDelta.tickProgress(tickCounter, false); + ModuleManager.renderHud(phase, ctx, tickProgress); + Renderer2D.Deferred2DLayer layer = deferredLayerForHudPhase(phase); + if (renderHudEngine(phase, layer, ctx, tickCounter)) { + appendHudDeferredMarker(layer, ctx); + } + } + + private static boolean renderHudEngine(HudPhase phase, + Renderer2D.Deferred2DLayer layer, + GuiGraphicsExtractor ctx, + DeltaTracker tickCounter) { + boolean rawMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW); + boolean scaledMain = hasHudMainPassWork(phase, HudRenderSpace.SCALED) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED); + boolean logicalMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL); + boolean rawForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW_FOREGROUND); + boolean scaledForeground = hasHudForegroundPassWork(phase, HudRenderSpace.SCALED) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED_FOREGROUND); + boolean logicalForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL) + || AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND); + if (!rawMain && !scaledMain && !logicalMain && !rawForeground && !scaledForeground && !logicalForeground) { + return false; + } + + float tickProgress = TickDelta.tickProgress(tickCounter, false); + TextRenderer textRenderer = TextRenderer.get(); + + FullScreenRenderer.ensureInit(); + ViewportContext.beginUnscaled(ctx); + refreshHudFrameTiming(tickCounter); + + Renderer2D.withDeferredLayer(layer, () -> { + SystemCursor.beginFrame(ctx); + try (RenderPhaseScope hudPhase = CombatantRenderSystem.phase(RenderPhase.HUD_MAIN, "2d:phase:" + phase.name())) { + // Raw HUD pass + if (rawMain) { + Renderer2D.COLOR.begin(); + ModuleManager.renderHudEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + + // Scaled fixed HUD pass + if (scaledMain) { + ViewportContext.beginScaled(ctx); + Renderer2D.COLOR.begin(); + StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + + // Logical HUD pass + if (logicalMain) { + ViewportContext.beginUnscaledLogical(ctx); + Renderer2D.COLOR.begin(); + StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + DraggableHudElementRegistry.renderAllEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + + // Raw foreground + if (rawForeground) { + ViewportContext.beginUnscaled(ctx); + Renderer2D.COLOR.begin(); + ModuleManager.renderHudEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + + // Scaled fixed HUD foreground + if (scaledForeground) { + ViewportContext.beginScaled(ctx); + Renderer2D.COLOR.begin(); + StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + + // Logical foreground + if (logicalForeground) { + ViewportContext.beginUnscaledLogical(ctx); + Renderer2D.COLOR.begin(); + StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + DraggableHudElementRegistry.renderAllEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress); + Renderer2D.COLOR.render(); + } + } finally { + SystemCursor.endFrame(); + ViewportContext.end(ctx); + } + }); + return true; + } + + private static void renderHudAfterHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) { + if (!RuntimeGate.canRunHud()) return; + boolean swapTooltipWork = SwapTooltip.hasWork(); + boolean targetHudWork = DraggableHudElementRegistry.hasEngineWidgetWork("target_hud"); + if (!swapTooltipWork && !targetHudWork) return; + + float tickProgress = TickDelta.tickProgress(tickCounter, false); + TextRenderer textRenderer = TextRenderer.get(); + Renderer2D.Deferred2DLayer layer = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS; + + FullScreenRenderer.ensureInit(); + ViewportContext.beginUnscaled(ctx); + refreshHudFrameTiming(tickCounter); + + Renderer2D.withDeferredLayer(layer, () -> { + SystemCursor.beginFrame(ctx); + try (RenderPhaseScope hudPhase = CombatantRenderSystem.phase(RenderPhase.HUD_EFFECTS, "2d:after_hotbar")) { + // Raw pass + if (swapTooltipWork) { + try (ProfilerPhase.Scope tooltipsScope = ProfilerPhase.scope("2d:after_hotbar:swap_tooltip"); + RenderProfiler2D.Section ignoredTooltips = RenderProfiler2D.section("swap_tooltip")) { + Renderer2D.COLOR.begin(); + SwapTooltip.render(); + Renderer2D.COLOR.render(); + } + } + + // Logical pass + if (targetHudWork) { + ViewportContext.beginUnscaledLogical(ctx); + try (ProfilerPhase.Scope targetHudScope = ProfilerPhase.scope("2d:after_hotbar:target_hud"); + RenderProfiler2D.Section ignoredTargetHud = RenderProfiler2D.section("target_hud")) { + Renderer2D.COLOR.begin(); + DraggableHudElementRegistry.renderEngineWidget( + "target_hud", + Renderer2D.COLOR, + textRenderer, + ctx, + tickProgress + ); + Renderer2D.COLOR.render(); + } + } + } finally { + SystemCursor.endFrame(); + ViewportContext.end(ctx); + } + }); + appendHudDeferredMarker(layer, ctx); + } + + private static void appendHudDeferredMarker(Renderer2D.Deferred2DLayer layer, GuiGraphicsExtractor ctx) { + if (!(ctx instanceof IGuiGraphics graphics)) return; + ctx.nextStratum(); + graphics.combatant$addDeferredHudMarker(layer); + ctx.nextStratum(); + } + + private static Renderer2D.Deferred2DLayer deferredLayerForHudPhase(HudPhase phase) { + if (phase == null) return Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI; + return switch (phase) { + case NONE -> Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI; + case FIRST -> Renderer2D.Deferred2DLayer.HUD_FIRST; + case BEFORE_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_BEFORE_MISC_OVERLAYS; + case AFTER_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS; + case AFTER_BOSS_BAR -> Renderer2D.Deferred2DLayer.HUD_AFTER_BOSS_BAR; + case BEFORE_DEMO_TIMER -> Renderer2D.Deferred2DLayer.HUD_BEFORE_DEMO_TIMER; + case BEFORE_CHAT -> Renderer2D.Deferred2DLayer.HUD_BEFORE_CHAT; + case AFTER_SUBTITLES -> Renderer2D.Deferred2DLayer.HUD_AFTER_SUBTITLES; + case LAST -> Renderer2D.Deferred2DLayer.HUD_LAST; + }; + } + + private static boolean hasHudMainPassWork(HudPhase phase, HudRenderSpace space) { + boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineWork(phase); + boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, false); + boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, false); + return moduleWork || staticWork || draggableWork; + } + + private static boolean hasHudForegroundPassWork(HudPhase phase, HudRenderSpace space) { + boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineForegroundWork(phase); + boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, true); + boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, true); + return moduleWork || staticWork || draggableWork; + } + + @Override + public void onInitializeClient() { + CombatantRenderEngineBootstrap.init(); + PostProcessManager.register(new NetherPortalRiftPass()); + PostProcessManager.register(new SleepOverlayPass()); + MainConfig.get(); // Load global settings (debug flag) + AccountConfig.get(); + ProxyBackend.init(); + CommandManager.init(); + MediaSessionService.get().init(); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> MediaSessionService.get().shutdown()); + + // -------- CONFIG + BINDS ---------- + // Enabled addons declare built-in module exclusions before auto-discovery. + AddonManager.prepareModuleLoading(); + ModuleAutoLoader.load("dev.loki.lovisual.features.module.modules"); + AddonManager.init(); + // Initialize modules' enabled state and settings from new config system + ModuleManager.loadAllModuleConfigs(); + AddonManager.notifyClientReady(); + // Key binds are restored via Module config (KeyBindSetting) in ModuleManager.loadAllModuleConfigs() + HudElements.init(); // init HUD config + widgets + StaticHudElementBootstrap.init(); // init vanilla HUD elements config + Events.BUS.register(I18nPreflightContributors.INSTANCE); + Events.BUS.register(RenderPrewarmContributors.INSTANCE); + Events.BUS.register(RotationManager.INSTANCE); + Events.BUS.register(AttackPressing.INSTANCE); + Events.BUS.register(InventorySwap.INSTANCE); + Events.BUS.register(SprintController.INSTANCE); + Events.BUS.register(AntiBotTracker.INSTANCE); + Events.BUS.register(VulcanReachController.INSTANCE); + Events.BUS.register(CombatProtocolHeuristics.INSTANCE); + Events.BUS.register(PvpTracker.INSTANCE); + Events.BUS.register(PvpTargetTracker.INSTANCE); + Events.BUS.register(SelfBedTracker.INSTANCE); + Events.BUS.register(BoatSimulationCache.INSTANCE); + Events.BUS.register(PlayerSimulationCache.INSTANCE); + Events.BUS.register(BlinkManager.INSTANCE); + Events.BUS.register(FakeLagController.INSTANCE); + Events.BUS.register(BacktrackController.INSTANCE); + Events.BUS.register(TimerController.INSTANCE); + + // -------- КЛЮЧЕВЫЕ ИВЕНТЫ -------- + + // === ТИКИ === + + + ClientTickEvents.START_CLIENT_TICK.register(client -> { + if (client.player == null) return; + if (!RuntimeGate.canRunClientLogic()) return; + Events.BUS.post(new GameTickEvent()); + }); + + ClientTickEvents.END_CLIENT_TICK.register(client -> { + ModuleManager.tickRuntimeControllers(); + if (!RuntimeGate.canRunClientLogic()) return; + + if (!ClickGuiRenderer.isBlockingModuleKeybinds()) { + ModuleManager.handleModuleKeybinds(); + } + TargetManager.tick(); + ModuleManager.tickAll(); + DraggableHudElementRegistry.tickAll(); + StaticHudElementRegistry.tickAll(); + }); + + /* ====================== WORLD RENDER ====================== */ + + LevelRenderEvents.COLLECT_SUBMITS.register(ctx -> renderWorldPhase( + WorldPhase.BEFORE_ENTITIES, + ctx.poseStack(), + ctx.submitNodeCollector() + )); + + LevelRenderEvents.AFTER_SOLID_FEATURES.register(ctx -> renderWorldPhase( + WorldPhase.AFTER_ENTITIES, + ctx.poseStack(), + ctx.submitNodeCollector() + )); + + LevelRenderEvents.BEFORE_TRANSLUCENT_TERRAIN.register(ctx -> renderWorldPhase( + WorldPhase.BEFORE_TRANSLUCENT, + ctx.poseStack(), + ctx.submitNodeCollector() + )); + + LevelRenderEvents.END_MAIN.register(ctx -> renderWorldPhase( + WorldPhase.END_MAIN, + ctx.poseStack(), + ctx.submitNodeCollector() + )); + + + // === HUD 2D OVERLAYS === + + HudElementRegistry.addFirst( + Identifier.fromNamespaceAndPath("lovisual", "hud_first"), + (ctx, tc) -> renderHudPhase(HudPhase.FIRST, ctx, tc) + ); + + HudElementRegistry.addLast( + Identifier.fromNamespaceAndPath("lovisual", "hud_last"), + (ctx, tc) -> renderHudPhase(HudPhase.LAST, ctx, tc) + ); + + // остальные позиции HUD: + HudElementRegistry.attachElementBefore( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS, + Identifier.fromNamespaceAndPath("lovisual", "hud_before_misc"), + (ctx, tc) -> renderHudPhase(HudPhase.BEFORE_MISC_OVERLAYS, ctx, tc) + ); + + HudElementRegistry.attachElementAfter( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS, + Identifier.fromNamespaceAndPath("lovisual", "hud_after_misc"), + (ctx, tc) -> renderHudPhase(HudPhase.AFTER_MISC_OVERLAYS, ctx, tc) + ); + + HudElementRegistry.attachElementAfter( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SLEEP, + Identifier.fromNamespaceAndPath("lovisual", "hud_after_boss"), + (ctx, tc) -> renderHudPhase(HudPhase.AFTER_BOSS_BAR, ctx, tc) + ); + + HudElementRegistry.attachElementBefore( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.DEMO_TIMER, + Identifier.fromNamespaceAndPath("lovisual", "hud_before_demo"), + (ctx, tc) -> renderHudPhase(HudPhase.BEFORE_DEMO_TIMER, ctx, tc) + ); + + HudElementRegistry.attachElementBefore( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.CHAT, + Identifier.fromNamespaceAndPath("lovisual", "hud_before_chat"), + (ctx, tc) -> renderHudPhase(HudPhase.BEFORE_CHAT, ctx, tc) + ); + + HudElementRegistry.attachElementAfter( + net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SUBTITLES, + Identifier.fromNamespaceAndPath("lovisual", "hud_after_subtitles"), + (ctx, tc) -> renderHudPhase(HudPhase.AFTER_SUBTITLES, ctx, tc) + ); + + HudElementRegistry.attachElementAfter( + VanillaHudElements.HOTBAR, + Identifier.fromNamespaceAndPath("lovisual", "hud_after_hotbar"), + (ctx, tc) -> renderHudAfterHotbar(ctx, tc) + ); + + registerRuntimeLifecycle(); + ClientRuntime.markActive("client initialized"); + RenderPrewarmManager.prewarm("client initialized"); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonDescriptor.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonDescriptor.java new file mode 100644 index 0000000..9bd5a59 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonDescriptor.java @@ -0,0 +1,22 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import java.util.List; + +record AddonDescriptor( + String id, + String name, + String version, + String description, + List authors, + String iconPath, + int apiVersion, + String entrypointDefinition +) { +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonIssue.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonIssue.java new file mode 100644 index 0000000..e4645dc --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonIssue.java @@ -0,0 +1,21 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +public record AddonIssue( + String addonId, + Severity severity, + String message, + String detail +) { + public enum Severity { + INFO, + WARNING, + ERROR + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonManager.java new file mode 100644 index 0000000..af0c427 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonManager.java @@ -0,0 +1,540 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.addon.LoVisualAddon; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleAutoLoader; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.runtime.ClientRuntime; +import dev.loki.lovisual.runtime.RuntimeResumeParticipant; +import dev.loki.lovisual.runtime.RuntimeShutdownContext; +import dev.loki.lovisual.runtime.RuntimeShutdownParticipant; +import dev.loki.lovisual.util.logging.DebugLog; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.metadata.CustomValue; +import net.fabricmc.loader.api.metadata.ModMetadata; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public enum AddonManager { + ; + + public static final String ADDON_METADATA_KEY = "combatant:addon"; + + private static final Map ADDONS = new LinkedHashMap<>(); + private static final Map ENABLED_OVERRIDES = new LinkedHashMap<>(); + private static final Map MODULE_OWNERS = new LinkedHashMap<>(); + private static final Map DRAGGABLE_HUD_OWNERS = new LinkedHashMap<>(); + private static final Map STATIC_HUD_OWNERS = new LinkedHashMap<>(); + private static boolean discovered; + private static boolean moduleLoadingPrepared; + private static boolean initialized; + private static boolean clientReady; + + /** + * Discovers enabled addon entrypoints and lets them configure built-in module + * exclusions before {@code ModuleAutoLoader} starts instantiating modules. + */ + public static void prepareModuleLoading() { + ensureDiscovered(); + if (moduleLoadingPrepared) return; + moduleLoadingPrepared = true; + configureEnabledAddons(); + } + + public static void init() { + if (initialized) return; + prepareModuleLoading(); + if (!ModuleAutoLoader.isDiscoveryFinished()) { + return; + } + reconcileModuleExclusions(); + initialized = true; + initializeEnabledAddons(); + } + + /** + * Completes addon startup after module configs have been loaded and applied. + */ + public static void notifyClientReady() { + init(); + if (!initialized) return; + clientReady = true; + for (AddonRegistration registration : ADDONS.values()) { + notifyClientReady(registration); + } + } + + private static void ensureDiscovered() { + if (discovered) return; + discovered = true; + ENABLED_OVERRIDES.clear(); + ENABLED_OVERRIDES.putAll(AddonStateStore.loadEnabledOverrides()); + discoverEntrypoints(); + } + + public static void registerRuntimeLifecycle() { + ClientRuntime.setAddonCounter(AddonManager::activeCount); + ClientRuntime.registerParticipant(new RuntimeShutdownParticipant() { + @Override + public String id() { + return "addons"; + } + + @Override + public void shutdown(RuntimeShutdownContext context) { + suspendForRuntime(context); + } + }); + ClientRuntime.registerResumeParticipant(new RuntimeResumeParticipant() { + @Override + public String id() { + return "addons"; + } + + @Override + public void resume(String reason) { + resumeAfterRuntime(reason); + } + }); + } + + public static List snapshots() { + init(); + return ADDONS.values().stream() + .map(AddonRegistration::snapshot) + .sorted(Comparator.comparing(AddonSnapshot::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + public static AddonSnapshot snapshot(String id) { + init(); + AddonRegistration registration = ADDONS.get(normalizeId(id)); + return registration == null ? null : registration.snapshot(); + } + + public static boolean isActive(String id) { + init(); + AddonRegistration registration = ADDONS.get(normalizeId(id)); + return registration != null && registration.enabled && registration.status == AddonStatus.ACTIVE; + } + + public static List scan() { + init(); + List out = new ArrayList<>(); + for (AddonRegistration registration : ADDONS.values()) { + validateRegistration(registration); + out.addAll(registration.issues); + } + if (out.isEmpty()) { + out.add(new AddonIssue("combatant", AddonIssue.Severity.INFO, "No addon errors found", "")); + } + return List.copyOf(out); + } + + public static String moduleProfileOwnerId(String moduleId) { + String addonId = MODULE_OWNERS.get(normalizeId(moduleId)); + return addonId == null ? "module:" + moduleId : "addon:" + addonId + ":module:" + moduleId; + } + + public static String draggableHudProfileOwnerId(String elementId) { + String addonId = DRAGGABLE_HUD_OWNERS.get(normalizeId(elementId)); + return addonId == null ? "hud.draggable:" + elementId : "addon:" + addonId + ":hud.draggable:" + elementId; + } + + public static String staticHudProfileOwnerId(String elementId) { + String addonId = STATIC_HUD_OWNERS.get(normalizeId(elementId)); + return addonId == null ? "hud.static:" + elementId : "addon:" + addonId + ":hud.static:" + elementId; + } + + public static String profileDisplayName(String ownerId, String displayName) { + String addonId = addonIdFromProfileOwner(ownerId); + if (addonId == null) return displayName; + AddonRegistration registration = ADDONS.get(addonId); + String addonName = registration == null ? addonId : registration.descriptor.name(); + return addonName + " / " + displayName; + } + + public static String addonIdFromProfileOwner(String ownerId) { + if (ownerId == null || !ownerId.startsWith("addon:")) return null; + int start = "addon:".length(); + int end = ownerId.indexOf(':', start); + if (end <= start) return null; + return normalizeId(ownerId.substring(start, end)); + } + + public static boolean setEnabled(String id, boolean enabled) { + init(); + String normalizedId = normalizeId(id); + AddonRegistration registration = ADDONS.get(normalizedId); + if (registration == null) return false; + if (registration.enabled == enabled) return true; + + registration.enabled = enabled; + registration.restartRequired = false; + ENABLED_OVERRIDES.put(normalizedId, enabled); + AddonStateStore.saveEnabledOverrides(ENABLED_OVERRIDES); + + if (enabled) { + if (!registration.moduleConfigurationApplied) { + configureAddonModules(registration); + } + if (!registration.initialized) { + initializeAddon(registration); + } else { + resumeRuntimeContributions(registration); + resumeAddon(registration, "enabled by user"); + if (registration.status != AddonStatus.ERROR) { + registration.status = AddonStatus.ACTIVE; + } + } + if (clientReady) { + notifyClientReady(registration); + } + return true; + } + + registration.restartRequired = !registration.disabledBuiltInModules.isEmpty(); + if (!registration.initialized) { + registration.status = AddonStatus.DISABLED; + return true; + } + + suspendRuntimeContributions(registration); + suspendAddon(registration, "disabled by user"); + registration.status = AddonStatus.DISABLED; + return true; + } + + public static int activeCount() { + int count = 0; + for (AddonRegistration registration : ADDONS.values()) { + if (registration.status == AddonStatus.ACTIVE) count++; + } + return count; + } + + private static void discoverEntrypoints() { + for (var entrypoint : FabricLoader.getInstance() + .getEntrypointContainers(LoVisualAddon.ENTRYPOINT_KEY, LoVisualAddon.class)) { + ModMetadata metadata = entrypoint.getProvider().getMetadata(); + String id = normalizeId(metadata.getId()); + AddonDescriptor descriptor = descriptor(metadata, entrypoint.getDefinition()); + boolean enabled = ENABLED_OVERRIDES.getOrDefault(id, true); + AddonStatus status = enabled ? AddonStatus.DISCOVERED : AddonStatus.DISABLED; + AddonRegistration duplicate = ADDONS.get(id); + if (duplicate != null) { + duplicate.issue(AddonIssue.Severity.ERROR, "Duplicate addon id", metadata.getId()); + continue; + } + AddonRegistration registration = new AddonRegistration(descriptor, entrypoint, enabled, status); + validateRegistration(registration); + ADDONS.put(id, registration); + } + } + + private static void configureEnabledAddons() { + for (AddonRegistration registration : ADDONS.values()) { + if (registration.enabled) { + configureAddonModules(registration); + } + } + } + + private static void configureAddonModules(AddonRegistration registration) { + if (registration.moduleConfigurationApplied || !registration.enabled || registration.status == AddonStatus.ERROR) { + return; + } + try { + LoVisualAddon addon = addonInstance(registration); + LoVisualModuleLoadContextImpl context = new LoVisualModuleLoadContextImpl(registration); + addon.onConfigureModules(context); + context.commit(); + registration.moduleConfigurationApplied = true; + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Module configuration failed", t.toString()); + DebugLog.error("Addon module configuration failed: %s", t, registration.descriptor.id()); + } + } + + private static void reconcileModuleExclusions() { + for (AddonRegistration registration : ADDONS.values()) { + if (registration.disabledBuiltInModules.isEmpty()) continue; + registration.disabledBuiltInModules.removeIf(reference -> { + if (ModuleAutoLoader.wasDisableRequestMatched(reference, registration.descriptor.id())) { + return false; + } + registration.issue( + AddonIssue.Severity.WARNING, + "Built-in module exclusion target was not found", + reference + ); + return true; + }); + } + } + + private static void initializeEnabledAddons() { + for (AddonRegistration registration : ADDONS.values()) { + if (registration.enabled && registration.status != AddonStatus.ERROR) { + initializeAddon(registration); + } + } + } + + private static void initializeAddon(AddonRegistration registration) { + if (registration.initialized || !registration.enabled || registration.status == AddonStatus.ERROR) return; + if (!registration.moduleConfigurationApplied) { + configureAddonModules(registration); + if (registration.status == AddonStatus.ERROR) return; + } + try { + LoVisualAddon addon = addonInstance(registration); + addon.onInitialize(new LoVisualAddonContextImpl(registration)); + registration.initialized = true; + registration.status = AddonStatus.ACTIVE; + DebugLog.info("Addon initialized: %s", registration.descriptor.id()); + if (clientReady) { + notifyClientReady(registration); + } + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Initialization failed", t.toString()); + DebugLog.error("Addon initialization failed: %s", t, registration.descriptor.id()); + } + } + + private static void notifyClientReady(AddonRegistration registration) { + if (registration == null + || registration.clientReadyNotified + || !registration.initialized + || !registration.enabled + || registration.status != AddonStatus.ACTIVE) { + return; + } + try { + registration.addon.onClientReady( + new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()) + ); + registration.clientReadyNotified = true; + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Client ready callback failed", t.toString()); + DebugLog.error("Addon client ready callback failed: %s", t, registration.descriptor.id()); + } + } + + private static LoVisualAddon addonInstance(AddonRegistration registration) { + LoVisualAddon addon = registration.addon; + if (addon == null) { + addon = registration.entrypoint.getEntrypoint(); + registration.addon = addon; + } + return addon; + } + + private static void suspendForRuntime(RuntimeShutdownContext context) { + int suspended = 0; + for (AddonRegistration registration : ADDONS.values()) { + if (registration.addon == null || registration.status != AddonStatus.ACTIVE) continue; + try { + if (context.jarReplacement()) { + shutdownAddon(registration, context.reason()); + registration.status = AddonStatus.SHUTDOWN; + } else { + registration.addon.onRuntimeSuspended( + new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()) + ); + registration.status = AddonStatus.SUSPENDED; + } + suspended++; + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString()); + DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id()); + } + } + context.increment("addons_suspended", suspended); + } + + private static void resumeAfterRuntime(String reason) { + for (AddonRegistration registration : ADDONS.values()) { + if (registration.addon == null || registration.status != AddonStatus.SUSPENDED || !registration.enabled) { + continue; + } + resumeAddon(registration, reason); + if (registration.status != AddonStatus.ERROR) { + registration.status = AddonStatus.ACTIVE; + } + } + } + + private static void suspendAddon(AddonRegistration registration, String reason) { + if (registration.addon == null) return; + try { + registration.addon.onRuntimeSuspended(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())); + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString()); + DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id()); + } + } + + private static void resumeAddon(AddonRegistration registration, String reason) { + if (registration.addon == null) return; + try { + registration.addon.onRuntimeResumed(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())); + } catch (Throwable t) { + registration.status = AddonStatus.ERROR; + registration.issue(AddonIssue.Severity.ERROR, "Runtime resume failed", t.toString()); + DebugLog.error("Addon runtime resume failed: %s", t, registration.descriptor.id()); + } + } + + private static void shutdownAddon(AddonRegistration registration, String reason) { + if (registration.addon == null) return; + try { + registration.addon.onShutdown(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())); + } catch (Throwable t) { + registration.issue(AddonIssue.Severity.ERROR, "Shutdown failed", t.toString()); + DebugLog.error("Addon shutdown failed: %s", t, registration.descriptor.id()); + } + } + + private static void suspendRuntimeContributions(AddonRegistration registration) { + registration.suspendedModules.clear(); + registration.suspendedDraggableHudElements.clear(); + registration.suspendedStaticHudElements.clear(); + for (String id : registration.modules) { + Module module = ModuleManager.get(id); + if (module != null) { + registration.suspendedModules.put(id, module.isEnabled()); + ModuleManager.setRuntimeEnabledTransient(id, false); + } + } + for (String id : registration.draggableHudElements) { + DraggableHudElement element = DraggableHudElementRegistry.getById(id); + if (element != null) { + registration.suspendedDraggableHudElements.put(id, element.isEnabled()); + element.setRuntimeEnabledTransient(false); + } + } + for (String id : registration.staticHudElements) { + AbstractHudElement element = StaticHudElementRegistry.getById(id); + if (element != null) { + registration.suspendedStaticHudElements.put(id, element.isEnabled()); + element.setRuntimeEnabledTransient(false); + } + } + } + + private static void resumeRuntimeContributions(AddonRegistration registration) { + for (String id : registration.modules) { + Boolean enabled = registration.suspendedModules.get(id); + if (enabled != null) { + ModuleManager.setRuntimeEnabledTransient(id, enabled); + } + } + for (String id : registration.draggableHudElements) { + Boolean enabled = registration.suspendedDraggableHudElements.get(id); + DraggableHudElement element = DraggableHudElementRegistry.getById(id); + if (enabled != null && element != null) { + element.setRuntimeEnabledTransient(enabled); + } + } + for (String id : registration.staticHudElements) { + Boolean enabled = registration.suspendedStaticHudElements.get(id); + AbstractHudElement element = StaticHudElementRegistry.getById(id); + if (enabled != null && element != null) { + element.setRuntimeEnabledTransient(enabled); + } + } + registration.suspendedModules.clear(); + registration.suspendedDraggableHudElements.clear(); + registration.suspendedStaticHudElements.clear(); + } + + static void noteModuleOwner(String addonId, String moduleId) { + if (moduleId != null && !moduleId.isBlank()) { + MODULE_OWNERS.put(normalizeId(moduleId), normalizeId(addonId)); + } + } + + static void noteDraggableHudOwner(String addonId, String elementId) { + if (elementId != null && !elementId.isBlank()) { + DRAGGABLE_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId)); + } + } + + static void noteStaticHudOwner(String addonId, String elementId) { + if (elementId != null && !elementId.isBlank()) { + STATIC_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId)); + } + } + + private static void validateRegistration(AddonRegistration registration) { + if (registration.descriptor.apiVersion() != LoVisualAddon.API_VERSION) { + registration.issue( + AddonIssue.Severity.WARNING, + "Addon API version differs from client API", + "addon=" + registration.descriptor.apiVersion() + ", client=" + LoVisualAddon.API_VERSION + ); + } + if (registration.descriptor.iconPath() == null || registration.descriptor.iconPath().isBlank()) { + registration.issue(AddonIssue.Severity.WARNING, "Addon icon is missing", "Add icon in fabric.mod.json"); + } + if (registration.descriptor.entrypointDefinition() == null + || registration.descriptor.entrypointDefinition().isBlank()) { + registration.issue(AddonIssue.Severity.WARNING, "Entrypoint definition is unavailable", ""); + } + } + + private static AddonDescriptor descriptor(ModMetadata metadata, String entrypointDefinition) { + List authors = metadata.getAuthors().stream() + .map(person -> person.getName() == null ? "" : person.getName()) + .filter(name -> !name.isBlank()) + .toList(); + return new AddonDescriptor( + normalizeId(metadata.getId()), + blankFallback(metadata.getName(), metadata.getId()), + String.valueOf(metadata.getVersion()), + metadata.getDescription() == null ? "" : metadata.getDescription(), + authors, + metadata.getIconPath(64).or(() -> metadata.getIconPath(32)).orElse(""), + apiVersion(metadata), + entrypointDefinition + ); + } + + private static int apiVersion(ModMetadata metadata) { + CustomValue value = metadata.getCustomValue(ADDON_METADATA_KEY); + if (value == null || value.getType() != CustomValue.CvType.OBJECT) return LoVisualAddon.API_VERSION; + CustomValue api = value.getAsObject().get("api"); + if (api == null || api.getType() != CustomValue.CvType.NUMBER) return LoVisualAddon.API_VERSION; + return api.getAsNumber().intValue(); + } + + private static String blankFallback(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static String normalizeId(String id) { + return id == null ? "" : id.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRegistration.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRegistration.java new file mode 100644 index 0000000..2cb2cf7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRegistration.java @@ -0,0 +1,79 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.addon.LoVisualAddon; +import net.fabricmc.loader.api.entrypoint.EntrypointContainer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class AddonRegistration { + final AddonDescriptor descriptor; + final EntrypointContainer entrypoint; + final List modules = new ArrayList<>(); + final List disabledBuiltInModules = new ArrayList<>(); + final List draggableHudElements = new ArrayList<>(); + final List staticHudElements = new ArrayList<>(); + final List commands = new ArrayList<>(); + final List clickGuiSections = new ArrayList<>(); + final List moduleExtensions = new ArrayList<>(); + final List irisPatchManifests = new ArrayList<>(); + final List renderCallbacks = new ArrayList<>(); + final List postProcessPasses = new ArrayList<>(); + final List issues = new ArrayList<>(); + final Map suspendedModules = new LinkedHashMap<>(); + final Map suspendedDraggableHudElements = new LinkedHashMap<>(); + final Map suspendedStaticHudElements = new LinkedHashMap<>(); + LoVisualAddon addon; + boolean moduleConfigurationApplied; + boolean initialized; + boolean clientReadyNotified; + AddonStatus status; + boolean enabled; + boolean restartRequired; + + AddonRegistration(AddonDescriptor descriptor, + EntrypointContainer entrypoint, + boolean enabled, + AddonStatus status) { + this.descriptor = descriptor; + this.entrypoint = entrypoint; + this.enabled = enabled; + this.status = status; + } + + AddonSnapshot snapshot() { + return new AddonSnapshot( + descriptor.id(), + descriptor.name(), + descriptor.version(), + descriptor.description(), + descriptor.authors(), + descriptor.iconPath(), + descriptor.apiVersion(), + status, + enabled, + restartRequired, + modules.size(), + draggableHudElements.size(), + staticHudElements.size(), + commands.size(), + clickGuiSections.size(), + moduleExtensions.size(), + irisPatchManifests.size(), + List.copyOf(issues) + ); + } + + void issue(AddonIssue.Severity severity, String message, String detail) { + issues.add(new AddonIssue(descriptor.id(), severity, message, detail)); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRenderPipelineManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRenderPipelineManager.java new file mode 100644 index 0000000..67e8751 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonRenderPipelineManager.java @@ -0,0 +1,193 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import com.mojang.blaze3d.textures.GpuTextureView; +import com.mojang.blaze3d.vertex.PoseStack; +import dev.loki.lovisual.api.v0.render.LoVisualPostProcessCallback; +import dev.loki.lovisual.api.v0.render.LoVisualPostProcessContext; +import dev.loki.lovisual.api.v0.render.LoVisualRenderCallback; +import dev.loki.lovisual.api.v0.render.LoVisualRenderContext; +import dev.loki.lovisual.api.v0.render.LoVisualRenderStage; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.module.WorldPhase; +import dev.loki.lovisual.render.engine.postprocess.PostProcessManager; +import dev.loki.lovisual.render.engine.postprocess.PostProcessPass; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.logging.DebugLog; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public enum AddonRenderPipelineManager { + ; + + private static final Map> CALLBACKS = + new EnumMap<>(LoVisualRenderStage.class); + + static { + for (LoVisualRenderStage stage : LoVisualRenderStage.values()) { + CALLBACKS.put(stage, new ArrayList<>()); + } + } + + public static synchronized boolean register(String addonId, + String callbackId, + LoVisualRenderStage stage, + LoVisualRenderCallback callback) { + String normalizedAddonId = normalize(addonId); + String normalizedCallbackId = normalize(callbackId); + if (normalizedAddonId.isBlank() || normalizedCallbackId.isBlank() || stage == null || callback == null) { + return false; + } + List callbacks = CALLBACKS.get(stage); + for (RegisteredRenderCallback registered : callbacks) { + if (registered.addonId().equals(normalizedAddonId) && registered.callbackId().equals(normalizedCallbackId)) { + return false; + } + } + callbacks.add(new RegisteredRenderCallback(normalizedAddonId, normalizedCallbackId, callback)); + return true; + } + + public static boolean hasActiveCallbacks(LoVisualRenderStage stage) { + if (stage == null) return false; + List callbacks; + synchronized (AddonRenderPipelineManager.class) { + callbacks = List.copyOf(CALLBACKS.get(stage)); + } + for (RegisteredRenderCallback callback : callbacks) { + if (AddonManager.isActive(callback.addonId())) return true; + } + return false; + } + + public static boolean registerPostProcess(String addonId, + String passId, + PostProcessPass.Phase phase, + int priority, + LoVisualPostProcessCallback callback) { + String normalizedAddonId = normalize(addonId); + String normalizedPassId = normalize(passId); + if (normalizedAddonId.isBlank() || normalizedPassId.isBlank() || phase == null || callback == null) { + return false; + } + PostProcessManager.register(new AddonPostProcessPass(normalizedAddonId, normalizedPassId, phase, priority, callback)); + return true; + } + + public static void render2D(LoVisualRenderStage stage, + HudPhase hudPhase, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor guiContext, + float tickDelta) { + render(stage, hudPhase, null, renderer, null, null, textRenderer, guiContext, null, tickDelta); + } + + public static void render3D(LoVisualRenderStage stage, + WorldPhase worldPhase, + Renderer3D renderer, + Renderer3D depthRenderer, + PoseStack poseStack, + float tickDelta) { + render(stage, null, worldPhase, null, renderer, depthRenderer, null, null, poseStack, tickDelta); + } + + public static synchronized void unregisterAddon(String addonId) { + String normalizedAddonId = normalize(addonId); + for (List callbacks : CALLBACKS.values()) { + callbacks.removeIf(callback -> callback.addonId().equals(normalizedAddonId)); + } + } + + private static void render(LoVisualRenderStage stage, + HudPhase hudPhase, + WorldPhase worldPhase, + Renderer2D renderer2D, + Renderer3D renderer3D, + Renderer3D depthRenderer3D, + TextRenderer textRenderer, + GuiGraphicsExtractor guiContext, + PoseStack poseStack, + float tickDelta) { + List callbacks; + synchronized (AddonRenderPipelineManager.class) { + callbacks = List.copyOf(CALLBACKS.get(stage)); + } + for (RegisteredRenderCallback registered : callbacks) { + if (!AddonManager.isActive(registered.addonId())) continue; + try { + registered.callback().render(new LoVisualRenderContext( + registered.addonId(), + registered.callbackId(), + stage, + hudPhase, + worldPhase, + renderer2D, + renderer3D, + depthRenderer3D, + textRenderer, + guiContext, + poseStack, + tickDelta + )); + } catch (Throwable t) { + DebugLog.error("[Addons] Render callback failed: addon=%s id=%s", + t, registered.addonId(), registered.callbackId()); + } + } + } + + private static String normalize(String id) { + return id == null ? "" : id.trim().toLowerCase(Locale.ROOT); + } + + private record RegisteredRenderCallback(String addonId, + String callbackId, + LoVisualRenderCallback callback) { + } + + private record AddonPostProcessPass(String addonId, + String passId, + PostProcessPass.Phase phase, + int priority, + LoVisualPostProcessCallback callback) implements PostProcessPass { + @Override + public boolean isActive() { + return AddonManager.isActive(addonId); + } + + @Override + public boolean render(GpuTextureView src, GpuTextureView dst, float tickDelta) { + if (!isActive()) return false; + try { + return callback.render(new LoVisualPostProcessContext(addonId, passId, phase, src, dst, tickDelta)); + } catch (Throwable t) { + DebugLog.error("[Addons] Post-process callback failed: addon=%s id=%s", t, addonId, passId); + return false; + } + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public Phase getPhase() { + return phase; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonSnapshot.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonSnapshot.java new file mode 100644 index 0000000..ee410ba --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonSnapshot.java @@ -0,0 +1,32 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import java.util.List; + +public record AddonSnapshot( + String id, + String name, + String version, + String description, + List authors, + String iconPath, + int apiVersion, + AddonStatus status, + boolean enabled, + boolean restartRequired, + int modules, + int draggableHudElements, + int staticHudElements, + int commands, + int clickGuiSections, + int moduleExtensions, + int irisPatchManifests, + List issues +) { +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStateStore.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStateStore.java new file mode 100644 index 0000000..f5afb94 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStateStore.java @@ -0,0 +1,63 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.fabricmc.loader.api.FabricLoader; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +final class AddonStateStore { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private AddonStateStore() { + } + + static Map loadEnabledOverrides() { + Path file = stateFile(); + if (!Files.isRegularFile(file)) return new HashMap<>(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + State state = GSON.fromJson(reader, State.class); + if (state == null || state.enabled == null) return new HashMap<>(); + return new HashMap<>(state.enabled); + } catch (Throwable ignored) { + return new HashMap<>(); + } + } + + static void saveEnabledOverrides(Map enabled) { + Path file = stateFile(); + try { + Files.createDirectories(file.getParent()); + State state = new State(); + state.enabled = new HashMap<>(enabled); + try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(state, writer); + } + } catch (IOException ignored) { + } + } + + private static Path stateFile() { + return FabricLoader.getInstance().getConfigDir() + .resolve("combatant") + .resolve("addons.json"); + } + + private static final class State { + Map enabled = new HashMap<>(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStatus.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStatus.java new file mode 100644 index 0000000..28b0c4a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/AddonStatus.java @@ -0,0 +1,17 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +public enum AddonStatus { + DISCOVERED, + DISABLED, + ACTIVE, + SUSPENDED, + ERROR, + SHUTDOWN +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ClickGuiSectionManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ClickGuiSectionManager.java new file mode 100644 index 0000000..9a25d4b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ClickGuiSectionManager.java @@ -0,0 +1,144 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection; +import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiRenderContext; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.sections.ClickGuiSection; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public enum ClickGuiSectionManager { + ; + + private static final Map SECTIONS = new LinkedHashMap<>(); + + public static synchronized boolean register(String addonId, + String sectionId, + String label, + LoVisualClickGuiSection section) { + String normalizedAddonId = normalize(addonId); + String normalizedSectionId = normalize(sectionId); + if (normalizedAddonId.isBlank() || normalizedSectionId.isBlank() || section == null) { + return false; + } + String id = normalizedAddonId + ":" + normalizedSectionId; + if (SECTIONS.containsKey(id)) { + return false; + } + String title = label == null || label.isBlank() ? normalizedSectionId : label.trim(); + SECTIONS.put(id, new Entry( + normalizedAddonId, + id, + normalizedSectionId, + title, + new AddonSectionAdapter(section) + )); + return true; + } + + public static synchronized void unregisterAddon(String addonId) { + String normalizedAddonId = normalize(addonId); + SECTIONS.entrySet().removeIf(entry -> { + if (!entry.getValue().addonId().equals(normalizedAddonId)) { + return false; + } + try { + entry.getValue().section().onDeselected(); + } catch (Throwable ignored) { + } + return true; + }); + } + + public static synchronized List sections() { + return SECTIONS.values().stream() + .filter(entry -> AddonManager.isActive(entry.addonId())) + .toList(); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + public record Entry(String addonId, + String id, + String localId, + String label, + ClickGuiSection section) { + } + + private record AddonSectionAdapter(LoVisualClickGuiSection section) implements ClickGuiSection { + @Override + public void layout(float x, float y, float w, float h) { + section.layout(x, y, w, h); + } + + @Override + public void render(float mouseX, float mouseY) { + section.render( + new LoVisualClickGuiRenderContext( + ClickGuiRenderer.currentRenderer(), + ClickGuiRenderer.getInterRegular(), + ClickGuiRenderer.getInterMedium() + ), + mouseX, + mouseY + ); + } + + @Override + public void renderGlassPass(float alphaFactor) { + section.renderGlassPass(alphaFactor); + } + + @Override + public boolean mousePressed(float mouseX, float mouseY, int button) { + return section.mousePressed(mouseX, mouseY, button); + } + + @Override + public void mouseReleased(float mouseX, float mouseY, int button) { + section.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseScrolled(float mouseX, float mouseY, double amount) { + return section.mouseScrolled(mouseX, mouseY, amount); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return section.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return section.charTyped(chr, modifiers); + } + + @Override + public void onSelected() { + section.onSelected(); + } + + @Override + public void onDeselected() { + section.onDeselected(); + } + + @Override + public boolean isVisible() { + return section.isVisible(); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonContextImpl.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonContextImpl.java new file mode 100644 index 0000000..8c06fa4 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonContextImpl.java @@ -0,0 +1,206 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.addon.LoVisualAddonContext; +import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection; +import dev.loki.lovisual.api.v0.client.LoVisualClientApi; +import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension; +import dev.loki.lovisual.api.v0.render.LoVisualPostProcessCallback; +import dev.loki.lovisual.api.v0.render.LoVisualRenderCallback; +import dev.loki.lovisual.api.v0.render.LoVisualRenderStage; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandManager; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.render.engine.pipeline.CombatantRenderPipelines; +import dev.loki.lovisual.render.engine.postprocess.PostProcessPass; +import dev.loki.lovisual.render.iris.patch.ShaderPatchEngine; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; + +import java.nio.file.Path; + +final class LoVisualAddonContextImpl implements LoVisualAddonContext { + private final AddonRegistration registration; + + LoVisualAddonContextImpl(AddonRegistration registration) { + this.registration = registration; + } + + @Override + public String addonId() { + return registration.descriptor.id(); + } + + @Override + public LoVisualClientApi client() { + return LoVisualClientApi.get(); + } + + @Override + public ModContainer modContainer() { + return registration.entrypoint.getProvider(); + } + + @Override + public Path gameDir() { + return FabricLoader.getInstance().getGameDir(); + } + + @Override + public Path configDir() { + return FabricLoader.getInstance().getConfigDir(); + } + + @Override + public Path addonConfigDir() { + return configDir().resolve("lovisual").resolve("addons").resolve(addonId()); + } + + @Override + public void registerModule(Module module) { + if (module == null) return; + module.postInit(); + registration.modules.add(module.name()); + AddonManager.noteModuleOwner(addonId(), module.name()); + } + + @Override + public void registerDraggableHudElement(DraggableHudElement element) { + if (element == null) return; + DraggableHudElementRegistry.register(element); + registration.draggableHudElements.add(element.getId()); + AddonManager.noteDraggableHudOwner(addonId(), element.getId()); + } + + @Override + public void registerStaticHudElement(AbstractHudElement element) { + if (element == null) return; + StaticHudElementRegistry.register(element); + registration.staticHudElements.add(element.getId()); + AddonManager.noteStaticHudOwner(addonId(), element.getId()); + } + + @Override + public void registerCommand(ClientCommand command) { + if (command == null) return; + CommandManager.register(new AddonClientCommand(addonId(), command)); + registration.commands.add(command.metadata().id()); + } + + @Override + public void registerClickGuiSection(String sectionId, String label, LoVisualClickGuiSection section) { + if (ClickGuiSectionManager.register(addonId(), sectionId, label, section)) { + registration.clickGuiSections.add(sectionId); + } else { + registration.issue(AddonIssue.Severity.WARNING, "ClickGui section registration failed", sectionId); + } + } + + @Override + public void registerModuleExtension(String moduleId, LoVisualModuleExtension extension) { + if (ModuleExtensionManager.register(addonId(), moduleId, extension)) { + registration.moduleExtensions.add(moduleId); + } else { + registration.issue(AddonIssue.Severity.WARNING, "Module extension target is unavailable", moduleId); + } + } + + @Override + public void registerIrisShaderPatchManifest(String manifestResourcePath) { + if (ShaderPatchEngine.registerManifestResource(manifestResourcePath)) { + registration.irisPatchManifests.add(manifestResourcePath); + } else { + registration.issue(AddonIssue.Severity.WARNING, "Iris patch manifest registration failed", manifestResourcePath); + } + } + + @Override + public void registerRenderCallback(String callbackId, LoVisualRenderStage stage, LoVisualRenderCallback callback) { + if (AddonRenderPipelineManager.register(addonId(), callbackId, stage, callback)) { + registration.renderCallbacks.add(stage.name().toLowerCase(java.util.Locale.ROOT) + ":" + callbackId); + } else { + registration.issue(AddonIssue.Severity.WARNING, "Render callback registration failed", callbackId); + } + } + + @Override + public void registerPostProcessPass(String passId, + PostProcessPass.Phase phase, + int priority, + LoVisualPostProcessCallback callback) { + if (AddonRenderPipelineManager.registerPostProcess(addonId(), passId, phase, priority, callback)) { + registration.postProcessPasses.add(phase.name().toLowerCase(java.util.Locale.ROOT) + ":" + passId); + } else { + registration.issue(AddonIssue.Severity.WARNING, "Post-process registration failed", passId); + } + } + + @Override + public RenderPipeline registerRenderPipeline(RenderPipeline pipeline) { + if (pipeline == null) return null; + RenderPipeline registered = CombatantRenderPipelines.registerAddonPipeline(pipeline); + registration.renderCallbacks.add("pipeline:" + pipeline.getLocation()); + return registered; + } + + private record AddonClientCommand(String addonId, ClientCommand delegate) implements ClientCommand { + @Override + public dev.loki.lovisual.features.command.CommandMetadata metadata() { + return delegate.metadata(); + } + + @Deprecated(forRemoval = true) + @Override + @SuppressWarnings("removal") + public String name() { + return delegate.name(); + } + + @Deprecated(forRemoval = true) + @Override + @SuppressWarnings("removal") + public java.util.List aliases() { + return delegate.aliases(); + } + + @Deprecated(forRemoval = true) + @Override + @SuppressWarnings("removal") + public String usage() { + return delegate.usage(); + } + + @Deprecated(forRemoval = true) + @Override + @SuppressWarnings("removal") + public String description() { + return delegate.description(); + } + + @Override + public boolean isAvailable() { + return AddonManager.isActive(addonId) && delegate.isAvailable(); + } + + @Override + public java.util.List suggest(dev.loki.lovisual.features.command.CommandContext ctx, int argIndex, String token) { + return delegate.suggest(ctx, argIndex, token); + } + + @Override + public boolean execute(dev.loki.lovisual.features.command.CommandContext ctx) { + return isAvailable() && delegate.execute(ctx); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonRuntimeContextImpl.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonRuntimeContextImpl.java new file mode 100644 index 0000000..6679f06 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualAddonRuntimeContextImpl.java @@ -0,0 +1,29 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.addon.LoVisualAddonRuntimeContext; +import dev.loki.lovisual.api.v0.client.LoVisualClientApi; + +final class LoVisualAddonRuntimeContextImpl implements LoVisualAddonRuntimeContext { + private final String addonId; + + LoVisualAddonRuntimeContextImpl(String addonId) { + this.addonId = addonId; + } + + @Override + public String addonId() { + return addonId; + } + + @Override + public LoVisualClientApi client() { + return LoVisualClientApi.get(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualModuleLoadContextImpl.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualModuleLoadContextImpl.java new file mode 100644 index 0000000..a875d68 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/LoVisualModuleLoadContextImpl.java @@ -0,0 +1,68 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.addon.LoVisualModuleLoadContext; +import dev.loki.lovisual.features.module.ModuleAutoLoader; + +import java.util.ArrayList; +import java.util.List; + +final class LoVisualModuleLoadContextImpl implements LoVisualModuleLoadContext { + private final AddonRegistration registration; + private final List pendingExclusions = new ArrayList<>(); + + LoVisualModuleLoadContextImpl(AddonRegistration registration) { + this.registration = registration; + } + + @Override + public String addonId() { + return registration.descriptor.id(); + } + + @Override + public boolean disableBuiltInModule(String moduleReference) { + String reference = moduleReference == null ? "" : moduleReference.trim(); + if (reference.isEmpty()) return false; + + if (!ModuleAutoLoader.acceptsDisableRequests()) { + noteRestartRequired(reference); + return false; + } + + if (!pendingExclusions.contains(reference)) { + pendingExclusions.add(reference); + } + return true; + } + + void commit() { + for (String reference : pendingExclusions) { + if (!registration.disabledBuiltInModules.contains(reference)) { + registration.disabledBuiltInModules.add(reference); + } + if (!ModuleAutoLoader.requestDisable(reference, addonId())) { + noteRestartRequired(reference); + } + } + pendingExclusions.clear(); + } + + private void noteRestartRequired(String reference) { + if (!registration.disabledBuiltInModules.contains(reference)) { + registration.disabledBuiltInModules.add(reference); + } + registration.restartRequired = true; + registration.issue( + AddonIssue.Severity.WARNING, + "Built-in module exclusion requires restart", + reference + ); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionContextImpl.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionContextImpl.java new file mode 100644 index 0000000..bdd8e2a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionContextImpl.java @@ -0,0 +1,55 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.module.ModuleExtensionContext; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.config.values.ModeValue; +import dev.loki.lovisual.features.module.Module; + +final class ModuleExtensionContextImpl implements ModuleExtensionContext { + private final String addonId; + private final Module module; + + ModuleExtensionContextImpl(String addonId, Module module) { + this.addonId = addonId; + this.module = module; + } + + @Override + public String addonId() { + return addonId; + } + + @Override + public String moduleId() { + return module.name(); + } + + @Override + public Module module() { + return module; + } + + @Override + public void addSetting(SettingDef setting) { + module.addExtensionSetting(setting); + } + + @Override + public ConfigValue getConfigValue(String configName) { + return module.getConfigValue(configName); + } + + @Override + public boolean addModeOption(String configName, String option) { + ConfigValue value = getConfigValue(configName); + return value instanceof ModeValue mode && mode.addOption(option); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionManager.java new file mode 100644 index 0000000..29decab --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/addon/ModuleExtensionManager.java @@ -0,0 +1,171 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.addon; + +import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.logging.DebugLog; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public enum ModuleExtensionManager { + ; + + private static final Map> EXTENSIONS = new LinkedHashMap<>(); + + public static boolean register(String addonId, String moduleId, LoVisualModuleExtension extension) { + if (extension == null || moduleId == null || moduleId.isBlank()) return false; + Module module = ModuleManager.get(moduleId); + if (module == null) { + DebugLog.warn("[Addons] Module extension target missing: addon=%s module=%s", addonId, moduleId); + return false; + } + String normalized = normalize(moduleId); + RegisteredExtension registered = new RegisteredExtension(addonId, extension); + EXTENSIONS.computeIfAbsent(normalized, ignored -> new ArrayList<>()).add(registered); + try { + extension.onRegister(new ModuleExtensionContextImpl(addonId, module)); + } catch (Throwable t) { + DebugLog.error("[Addons] Module extension registration failed: %s", t, normalized); + return false; + } + return true; + } + + public static int count() { + int count = 0; + for (List list : EXTENSIONS.values()) { + count += list.size(); + } + return count; + } + + public static boolean beforeEnable(Module module) { + return all(module, extension -> extension.beforeEnable(module)); + } + + public static void afterEnable(Module module) { + each(module, extension -> extension.afterEnable(module)); + } + + public static boolean beforeDisable(Module module) { + return all(module, extension -> extension.beforeDisable(module)); + } + + public static void afterDisable(Module module) { + each(module, extension -> extension.afterDisable(module)); + } + + public static boolean beforeTick(Module module) { + return all(module, extension -> extension.beforeTick(module)); + } + + public static void afterTick(Module module) { + each(module, extension -> extension.afterTick(module)); + } + + public static boolean beforeFrame(Module module, float frameDeltaTicks) { + return all(module, extension -> extension.beforeFrame(module, frameDeltaTicks)); + } + + public static void afterFrame(Module module, float frameDeltaTicks) { + each(module, extension -> extension.afterFrame(module, frameDeltaTicks)); + } + + public static boolean beforeHudRender(Module module, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + return all(module, extension -> extension.beforeHudRender(module, renderer, textRenderer, ctx, tickDelta)); + } + + public static void afterHudRender(Module module, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + each(module, extension -> extension.afterHudRender(module, renderer, textRenderer, ctx, tickDelta)); + } + + public static boolean beforeWorldRender(Module module, + Renderer3D renderer, + Renderer3D depthRenderer, + float tickDelta) { + return all(module, extension -> extension.beforeWorldRender(module, renderer, depthRenderer, tickDelta)); + } + + public static void afterWorldRender(Module module, + Renderer3D renderer, + Renderer3D depthRenderer, + float tickDelta) { + each(module, extension -> extension.afterWorldRender(module, renderer, depthRenderer, tickDelta)); + } + + private static boolean all(Module module, ExtensionBooleanCall call) { + List extensions = extensions(module); + if (extensions.isEmpty()) return true; + boolean proceed = true; + for (RegisteredExtension registered : extensions) { + if (!AddonManager.isActive(registered.addonId())) continue; + try { + proceed &= call.invoke(registered.extension()); + } catch (Throwable t) { + DebugLog.error("[Addons] Module extension hook failed: addon=%s module=%s", + t, registered.addonId(), module == null ? "" : module.name()); + } + } + return proceed; + } + + private static void each(Module module, ExtensionVoidCall call) { + List extensions = extensions(module); + if (extensions.isEmpty()) return; + for (RegisteredExtension registered : extensions) { + if (!AddonManager.isActive(registered.addonId())) continue; + try { + call.invoke(registered.extension()); + } catch (Throwable t) { + DebugLog.error("[Addons] Module extension hook failed: addon=%s module=%s", + t, registered.addonId(), module == null ? "" : module.name()); + } + } + } + + private static List extensions(Module module) { + if (module == null) return List.of(); + List list = EXTENSIONS.get(normalize(module.name())); + return list == null ? List.of() : List.copyOf(list); + } + + private static String normalize(String id) { + return id == null ? "" : id.trim().toLowerCase(Locale.ROOT); + } + + @FunctionalInterface + private interface ExtensionBooleanCall { + boolean invoke(LoVisualModuleExtension extension); + } + + @FunctionalInterface + private interface ExtensionVoidCall { + void invoke(LoVisualModuleExtension extension); + } + + private record RegisteredExtension(String addonId, LoVisualModuleExtension extension) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddon.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddon.java new file mode 100644 index 0000000..d1d0aa5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddon.java @@ -0,0 +1,45 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.addon; + +/** + * Fabric entrypoint contract for Combatant addons. + * + *

Declare it in fabric.mod.json under the {@value #ENTRYPOINT_KEY} entrypoint. + */ +public interface LoVisualAddon { + String ENTRYPOINT_KEY = "combatant:addon"; + int API_VERSION = 0; + + /** + * Called before LoVisual auto-loads its built-in modules. + * + *

Use this hook only for startup module exclusions. Normal addon registration + * still belongs in {@link #onInitialize(LoVisualAddonContext)}.

+ */ + default void onConfigureModules(LoVisualModuleLoadContext context) { + } + + void onInitialize(LoVisualAddonContext context); + + /** + * Called after all built-in and addon module configs have been loaded and applied. + * This is the safe startup hook for mutating settings of existing modules. + */ + default void onClientReady(LoVisualAddonRuntimeContext context) { + } + + default void onRuntimeSuspended(LoVisualAddonRuntimeContext context) { + } + + default void onRuntimeResumed(LoVisualAddonRuntimeContext context) { + } + + default void onShutdown(LoVisualAddonRuntimeContext context) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonContext.java new file mode 100644 index 0000000..35215c4 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonContext.java @@ -0,0 +1,55 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.addon; + +import dev.loki.lovisual.events.UsedImplicitly; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension; +import dev.loki.lovisual.api.v0.render.LoVisualPostProcessCallback; +import dev.loki.lovisual.api.v0.render.LoVisualRenderCallback; +import dev.loki.lovisual.api.v0.render.LoVisualRenderStage; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import dev.loki.lovisual.render.engine.postprocess.PostProcessPass; +import net.fabricmc.loader.api.ModContainer; + +import java.nio.file.Path; + +public interface LoVisualAddonContext extends LoVisualAddonRuntimeContext { + @UsedImplicitly + ModContainer modContainer(); + @UsedImplicitly + Path gameDir(); + + Path configDir(); + @UsedImplicitly + Path addonConfigDir(); + @UsedImplicitly + void registerModule(Module module); + @UsedImplicitly + void registerDraggableHudElement(DraggableHudElement element); + @UsedImplicitly + void registerStaticHudElement(AbstractHudElement element); + @UsedImplicitly + void registerCommand(ClientCommand command); + @UsedImplicitly + void registerClickGuiSection(String sectionId, String label, LoVisualClickGuiSection section); + @UsedImplicitly + void registerModuleExtension(String moduleId, LoVisualModuleExtension extension); + @UsedImplicitly + void registerIrisShaderPatchManifest(String manifestResourcePath); + @UsedImplicitly + void registerRenderCallback(String callbackId, LoVisualRenderStage stage, LoVisualRenderCallback callback); + @UsedImplicitly + void registerPostProcessPass(String passId, PostProcessPass.Phase phase, int priority, LoVisualPostProcessCallback callback); + @UsedImplicitly + RenderPipeline registerRenderPipeline(RenderPipeline pipeline); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonRuntimeContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonRuntimeContext.java new file mode 100644 index 0000000..7ac4b80 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualAddonRuntimeContext.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.addon; + +import dev.loki.lovisual.api.v0.client.LoVisualClientApi; + +public interface LoVisualAddonRuntimeContext { + String addonId(); + + LoVisualClientApi client(); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualModuleLoadContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualModuleLoadContext.java new file mode 100644 index 0000000..2cd62cf --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/addon/LoVisualModuleLoadContext.java @@ -0,0 +1,43 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.addon; + +import dev.loki.lovisual.events.UsedImplicitly; +import dev.loki.lovisual.features.module.Module; + +/** + * Early addon context used before Combatant's built-in modules are instantiated. + * + *

Only enabled addons receive this callback. Exclusions are startup-only: changing + * an addon's enabled state after module discovery requires a client restart for the + * built-in module set to be rebuilt.

+ */ +public interface LoVisualModuleLoadContext { + @UsedImplicitly + String addonId(); + + /** + * Prevents an auto-loaded built-in module from being instantiated. + * + *

The reference may be a {@code ModuleInfo.id}, module alias, simple class name, + * or fully-qualified class name.

+ * + * @return {@code true} when the exclusion was accepted for the current startup; + * {@code false} when the reference is invalid or module discovery has already started + */ + @UsedImplicitly + boolean disableBuiltInModule(String moduleReference); + + /** + * Class-based convenience overload for addons that directly depend on a built-in module type. + */ + @UsedImplicitly + default boolean disableBuiltInModule(Class moduleType) { + return moduleType != null && disableBuiltInModule(moduleType.getName()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiRenderContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiRenderContext.java new file mode 100644 index 0000000..a8db800 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiRenderContext.java @@ -0,0 +1,18 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.clickgui; + +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +public record LoVisualClickGuiRenderContext( + Renderer2D renderer, + TextRenderer regularFont, + TextRenderer mediumFont +) { +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiSection.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiSection.java new file mode 100644 index 0000000..d5f35ff --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/clickgui/LoVisualClickGuiSection.java @@ -0,0 +1,48 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.clickgui; + +public interface LoVisualClickGuiSection { + default void layout(float x, float y, float w, float h) { + } + + default void render(LoVisualClickGuiRenderContext context, float mouseX, float mouseY) { + } + + default void renderGlassPass(float alphaFactor) { + } + + default boolean mousePressed(float mouseX, float mouseY, int button) { + return false; + } + + default void mouseReleased(float mouseX, float mouseY, int button) { + } + + default boolean mouseScrolled(float mouseX, float mouseY, double amount) { + return false; + } + + default boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return false; + } + + default boolean charTyped(char chr, int modifiers) { + return false; + } + + default void onSelected() { + } + + default void onDeselected() { + } + + default boolean isVisible() { + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/client/LoVisualClientApi.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/client/LoVisualClientApi.java new file mode 100644 index 0000000..4479094 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/client/LoVisualClientApi.java @@ -0,0 +1,40 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.client; + +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; + +import java.util.List; + +public final class LoVisualClientApi { + private static final LoVisualClientApi INSTANCE = new LoVisualClientApi(); + + private LoVisualClientApi() { + } + + public static LoVisualClientApi get() { + return INSTANCE; + } + + public List modules() { + return ModuleManager.getModules(); + } + + public Module module(String id) { + return ModuleManager.get(id); + } + + public boolean isModuleEnabled(String id) { + return ModuleManager.isEnabled(id); + } + + public void setModuleEnabled(String id, boolean enabled) { + ModuleManager.setEnabled(id, enabled); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/LoVisualModuleExtension.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/LoVisualModuleExtension.java new file mode 100644 index 0000000..8d585cc --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/LoVisualModuleExtension.java @@ -0,0 +1,75 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.module; + +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public interface LoVisualModuleExtension { + default void onRegister(ModuleExtensionContext context) { + } + + default boolean beforeEnable(Module module) { + return true; + } + + default void afterEnable(Module module) { + } + + default boolean beforeDisable(Module module) { + return true; + } + + default void afterDisable(Module module) { + } + + default boolean beforeTick(Module module) { + return true; + } + + default void afterTick(Module module) { + } + + default boolean beforeFrame(Module module, float frameDeltaTicks) { + return true; + } + + default void afterFrame(Module module, float frameDeltaTicks) { + } + + default boolean beforeHudRender(Module module, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + return true; + } + + default void afterHudRender(Module module, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + } + + default boolean beforeWorldRender(Module module, + Renderer3D renderer, + Renderer3D depthRenderer, + float tickDelta) { + return true; + } + + default void afterWorldRender(Module module, + Renderer3D renderer, + Renderer3D depthRenderer, + float tickDelta) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/ModuleExtensionContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/ModuleExtensionContext.java new file mode 100644 index 0000000..0973931 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/module/ModuleExtensionContext.java @@ -0,0 +1,26 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.module; + +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.features.module.Module; + +public interface ModuleExtensionContext { + String addonId(); + + String moduleId(); + + Module module(); + + void addSetting(SettingDef setting); + + ConfigValue getConfigValue(String configName); + + boolean addModeOption(String configName, String option); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessCallback.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessCallback.java new file mode 100644 index 0000000..e6da276 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessCallback.java @@ -0,0 +1,13 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +@FunctionalInterface +public interface LoVisualPostProcessCallback { + boolean render(LoVisualPostProcessContext context); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessContext.java new file mode 100644 index 0000000..b32282a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualPostProcessContext.java @@ -0,0 +1,25 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +import com.mojang.blaze3d.textures.GpuTextureView; +import dev.loki.lovisual.render.engine.postprocess.PostProcessPass; +import net.minecraft.client.Minecraft; + +public record LoVisualPostProcessContext( + String addonId, + String passId, + PostProcessPass.Phase phase, + GpuTextureView source, + GpuTextureView destination, + float tickDelta +) { + public Minecraft client() { + return Minecraft.getInstance(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderCallback.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderCallback.java new file mode 100644 index 0000000..1849f78 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderCallback.java @@ -0,0 +1,13 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +@FunctionalInterface +public interface LoVisualRenderCallback { + void render(LoVisualRenderContext context); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderContext.java new file mode 100644 index 0000000..e738981 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderContext.java @@ -0,0 +1,36 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.module.WorldPhase; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +public record LoVisualRenderContext( + String addonId, + String callbackId, + LoVisualRenderStage stage, + HudPhase hudPhase, + WorldPhase worldPhase, + Renderer2D renderer2D, + Renderer3D renderer3D, + Renderer3D depthRenderer3D, + TextRenderer textRenderer, + GuiGraphicsExtractor guiContext, + PoseStack poseStack, + float tickDelta +) { + public Minecraft client() { + return Minecraft.getInstance(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderPipelineBuilder.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderPipelineBuilder.java new file mode 100644 index 0000000..706c8b6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderPipelineBuilder.java @@ -0,0 +1,109 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +import com.mojang.blaze3d.PrimitiveTopology; +import com.mojang.blaze3d.pipeline.BlendFunction; +import com.mojang.blaze3d.pipeline.RenderPipeline; +import com.mojang.blaze3d.shaders.UniformType; +import com.mojang.blaze3d.vertex.VertexFormat; +import dev.loki.lovisual.render.engine.pipeline.CombatantRenderPipelines; +import dev.loki.lovisual.render.engine.pipeline.DepthTestFunction; +import dev.loki.lovisual.render.engine.pipeline.ExtendedRenderPipelineBuilder; +import dev.loki.lovisual.render.engine.rhi.clip.ShapeClipRenderPassContract; +import net.minecraft.resources.Identifier; + +@SuppressWarnings("unused") +public final class LoVisualRenderPipelineBuilder { + private final ExtendedRenderPipelineBuilder delegate; + + private LoVisualRenderPipelineBuilder(RenderPipeline.Snippet... snippets) { + this.delegate = new ExtendedRenderPipelineBuilder(snippets); + } + + public static LoVisualRenderPipelineBuilder create(RenderPipeline.Snippet... snippets) { + return new LoVisualRenderPipelineBuilder(snippets); + } + + public static RenderPipeline.Snippet meshUniforms() { + return CombatantRenderPipelines.meshUniforms(); + } + + public LoVisualRenderPipelineBuilder location(Identifier id) { + delegate.withLocation(id); + return this; + } + + public LoVisualRenderPipelineBuilder vertexFormat(VertexFormat format, PrimitiveTopology mode) { + delegate.withVertexFormat(format, mode); + return this; + } + + public LoVisualRenderPipelineBuilder vertexShader(Identifier id) { + delegate.withVertexShader(id); + return this; + } + + public LoVisualRenderPipelineBuilder fragmentShader(Identifier id) { + delegate.withFragmentShader(id); + return this; + } + + public LoVisualRenderPipelineBuilder depthTest(DepthTestFunction function) { + delegate.withDepthTestFunction(function); + return this; + } + + public LoVisualRenderPipelineBuilder depthWrite(boolean write) { + delegate.withDepthWrite(write); + return this; + } + + public LoVisualRenderPipelineBuilder blend(BlendFunction blend) { + delegate.withBlend(blend); + return this; + } + + public LoVisualRenderPipelineBuilder cull(boolean cull) { + delegate.withCull(cull); + return this; + } + + public LoVisualRenderPipelineBuilder sampler(String name) { + delegate.withSampler(name); + return this; + } + + public LoVisualRenderPipelineBuilder uniform(String name, UniformType type) { + delegate.withUniform(name, type); + return this; + } + + public LoVisualRenderPipelineBuilder lineSmooth() { + delegate.withLineSmooth(); + return this; + } + + public LoVisualRenderPipelineBuilder shapeClipSupport() { + delegate.withShapeClipSupport(); + return this; + } + + public LoVisualRenderPipelineBuilder shapeClipContract(ShapeClipRenderPassContract contract) { + delegate.withShapeClipContract(contract); + return this; + } + + public RenderPipeline build() { + return delegate.build(); + } + + public RenderPipeline buildAndRegister() { + return CombatantRenderPipelines.registerAddonPipeline(build()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderStage.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderStage.java new file mode 100644 index 0000000..5a89d0e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualRenderStage.java @@ -0,0 +1,22 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +public enum LoVisualRenderStage { + HUD_RAW, + HUD_SCALED, + HUD_LOGICAL, + HUD_RAW_FOREGROUND, + HUD_SCALED_FOREGROUND, + HUD_LOGICAL_FOREGROUND, + WORLD_BEFORE_TRANSLUCENT, + WORLD_END_MAIN, + WORLD_POST_PROCESS, + SCREEN_BEFORE_VANILLA_GUI, + SCREEN_AFTER_VANILLA_GUI +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualUniforms.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualUniforms.java new file mode 100644 index 0000000..e3d2c56 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/api/v0/render/LoVisualUniforms.java @@ -0,0 +1,74 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.api.v0.render; + +import com.mojang.blaze3d.buffers.GpuBufferSlice; +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; + +import java.nio.ByteBuffer; + +@SuppressWarnings("unused") +public enum LoVisualUniforms { + ; + + public static GpuBufferSlice write(String name, + int std140Size, + int expectedWritesPerFrame, + UniformWriter writer) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Uniform name cannot be blank"); + } + if (std140Size <= 0) { + throw new IllegalArgumentException("Uniform std140 size must be positive"); + } + if (writer == null) { + throw new IllegalArgumentException("Uniform writer cannot be null"); + } + return CombatantRenderSystem.uniforms().write(name, std140Size, expectedWritesPerFrame, writer::write); + } + + public static GpuBufferSlice writeCached(String name, + int std140Size, + int expectedWritesPerFrame, + int updateEveryFrames, + boolean force, + UniformWriter writer) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Uniform name cannot be blank"); + } + if (std140Size <= 0) { + throw new IllegalArgumentException("Uniform std140 size must be positive"); + } + if (writer == null) { + throw new IllegalArgumentException("Uniform writer cannot be null"); + } + return CombatantRenderSystem.uniforms().writeCached( + name, + std140Size, + expectedWritesPerFrame, + updateEveryFrames, + force, + writer::write + ); + } + + public static GpuBufferSlice current(String name) { + if (name == null || name.isBlank()) return null; + return CombatantRenderSystem.uniforms().current(name); + } + + public static void invalidate(String name) { + if (name == null || name.isBlank()) return; + CombatantRenderSystem.uniforms().invalidate(name); + } + + @FunctionalInterface + public interface UniformWriter { + void write(ByteBuffer buffer); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroMinimapCompat.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroMinimapCompat.java new file mode 100644 index 0000000..84febeb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroMinimapCompat.java @@ -0,0 +1,75 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import org.jetbrains.annotations.Nullable; +import xaero.common.minimap.waypoints.Waypoint; +import xaero.hud.minimap.waypoint.WaypointColor; +import xaero.hud.minimap.waypoint.WaypointPurpose; + +public enum TriangulatorXaeroMinimapCompat { + ; + private static Waypoint hudWaypoint; + private static Waypoint minimapWaypoint; + private static boolean hudActive; + private static boolean minimapActive; + + @Nullable + public static Waypoint getHudWaypoint() { + sync(); + return hudActive ? hudWaypoint : null; + } + + @Nullable + public static Waypoint getMinimapWaypoint() { + sync(); + return minimapActive ? minimapWaypoint : null; + } + + private static void sync() { + TriangulatorXaeroState.XaeroSnapshot snapshot = TriangulatorXaeroState.snapshot(); + if (snapshot == null) { + hudActive = false; + minimapActive = false; + return; + } + + TriangulatorXaeroState.SyncPoint point = TriangulatorXaeroState.toCurrentDimension(snapshot); + WaypointColor color = snapshot.ready() ? WaypointColor.GOLD : WaypointColor.RED; + + hudWaypoint = syncWaypoint(hudWaypoint, point, color); + minimapWaypoint = syncWaypoint(minimapWaypoint, point, color); + hudActive = true; + minimapActive = true; + } + + private static Waypoint syncWaypoint(@Nullable Waypoint waypoint, + TriangulatorXaeroState.SyncPoint point, + WaypointColor color) { + Waypoint result = waypoint; + if (result == null) { + result = new Waypoint( + point.x(), + point.y(), + point.z(), + TriangulatorXaeroState.NAME, + TriangulatorXaeroState.SYMBOL, + color, + WaypointPurpose.NORMAL + ); + result.setTemporary(false); + result.setYIncluded(false); + } + result.setX(point.x()); + result.setY(point.y()); + result.setZ(point.z()); + result.setWaypointColor(color); + result.setDisabled(false); + return result; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroState.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroState.java new file mode 100644 index 0000000..3926f0b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroState.java @@ -0,0 +1,87 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import net.minecraft.client.Minecraft; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Method; + +public enum TriangulatorXaeroState { + ; + public static final String NAME = "Stronghold"; + public static final String SYMBOL = "S"; + public static final String SET = "combatant"; + public static final int DEFAULT_Y = 64; + + @Nullable + public static XaeroSnapshot snapshot() { + DraggableHudElement triangulator = DraggableHudElementRegistry.getById("triangulator"); + if (triangulator == null) return null; + try { + Object raw = triangulator.getClass().getMethod("getXaeroSnapshot").invoke(triangulator); + if (raw == null) return null; + return new XaeroSnapshot( + readDouble(raw, "overworldX"), + readDouble(raw, "overworldZ"), + readBoolean(raw, "ready"), + readDouble(raw, "confidence"), + readInt(raw, "eyeCount") + ); + } catch (ReflectiveOperationException | ClassCastException ignored) { + return null; + } + } + + public static SyncPoint toCurrentDimension(XaeroSnapshot snapshot) { + boolean nether = isNetherView(); + double factor = nether ? 0.125 : 1.0; + return new SyncPoint( + (int) Math.round(snapshot.overworldX() * factor), + DEFAULT_Y, + (int) Math.round(snapshot.overworldZ() * factor) + ); + } + + public static boolean isNetherView() { + Minecraft mc = Minecraft.getInstance(); + return mc != null && mc.level != null && mc.level.dimension() == Level.NETHER; + } + + private static double readDouble(Object target, String method) throws ReflectiveOperationException { + Object value = invoke(target, method); + if (value instanceof Number number) return number.doubleValue(); + throw new ClassCastException(method); + } + + private static boolean readBoolean(Object target, String method) throws ReflectiveOperationException { + Object value = invoke(target, method); + if (value instanceof Boolean bool) return bool; + throw new ClassCastException(method); + } + + private static int readInt(Object target, String method) throws ReflectiveOperationException { + Object value = invoke(target, method); + if (value instanceof Number number) return number.intValue(); + throw new ClassCastException(method); + } + + private static Object invoke(Object target, String method) throws ReflectiveOperationException { + Method accessor = target.getClass().getMethod(method); + return accessor.invoke(target); + } + + public record XaeroSnapshot(double overworldX, double overworldZ, boolean ready, double confidence, int eyeCount) { + } + + public record SyncPoint(int x, int y, int z) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroWorldMapCompat.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroWorldMapCompat.java new file mode 100644 index 0000000..4db4e95 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/TriangulatorXaeroWorldMapCompat.java @@ -0,0 +1,40 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import org.jetbrains.annotations.Nullable; +import xaero.common.minimap.waypoints.Waypoint; +import xaero.hud.minimap.waypoint.WaypointColor; +import xaero.hud.minimap.waypoint.WaypointPurpose; +import xaero.map.mods.SupportXaeroMinimap; + +public enum TriangulatorXaeroWorldMapCompat { + ; + + @Nullable + public static xaero.map.mods.gui.Waypoint getWaypoint(SupportXaeroMinimap minimap) { + TriangulatorXaeroState.XaeroSnapshot snapshot = TriangulatorXaeroState.snapshot(); + if (snapshot == null || minimap == null) { + return null; + } + + Waypoint baseWaypoint = new Waypoint( + (int) Math.round(snapshot.overworldX()), + TriangulatorXaeroState.DEFAULT_Y, + (int) Math.round(snapshot.overworldZ()), + TriangulatorXaeroState.NAME, + TriangulatorXaeroState.SYMBOL, + snapshot.ready() ? WaypointColor.GOLD : WaypointColor.RED, + WaypointPurpose.NORMAL + ); + baseWaypoint.setTemporary(false); + baseWaypoint.setYIncluded(false); + baseWaypoint.setDisabled(false); + return minimap.convertWaypoint(baseWaypoint, false, TriangulatorXaeroState.SET, minimap.getDimDiv()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroCommandWaypointCompat.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroCommandWaypointCompat.java new file mode 100644 index 0000000..7ec6ffd --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroCommandWaypointCompat.java @@ -0,0 +1,31 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import org.jetbrains.annotations.Nullable; +import xaero.common.minimap.waypoints.Waypoint; +import xaero.map.mods.SupportXaeroMinimap; + +import java.util.List; + +/** + * Kept as a no-op compatibility bridge for older mixin code paths. + * @xwp markers are now inserted into Xaero's real waypoint sets by {@link XaeroWaypointRuntime}, + * otherwise World Map/minimap hover, open and delete logic cannot see them. + */ +public enum XaeroCommandWaypointCompat { + ; + + public static List getMinimapWaypoints() { + return List.of(); + } + + public static List getWorldMapWaypoints(@Nullable SupportXaeroMinimap minimap) { + return List.of(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointRuntime.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointRuntime.java new file mode 100644 index 0000000..e3ed0b2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointRuntime.java @@ -0,0 +1,229 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import net.minecraft.client.Minecraft; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import xaero.common.HudMod; +import xaero.common.minimap.waypoints.Waypoint; +import xaero.hud.minimap.BuiltInHudModules; +import xaero.hud.minimap.module.MinimapSession; +import xaero.hud.minimap.waypoint.WaypointColor; +import xaero.hud.minimap.waypoint.WaypointPurpose; +import xaero.hud.minimap.waypoint.set.WaypointSet; +import xaero.hud.minimap.world.MinimapWorld; +import xaero.hud.minimap.world.MinimapWorldManager; +import xaero.hud.path.XaeroPath; + +import java.util.ArrayList; +import java.util.List; + +final class XaeroWaypointRuntime { + private XaeroWaypointRuntime() { + } + + static XaeroWaypointStore.RuntimeAddResult addFromCurrentDimension(int x, + int y, + int z, + String name, + boolean dualDefault) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.level == null) { + return fail("No client world is loaded."); + } + + MinimapSession session = currentSession(); + if (session == null) { + return fail("Xaero minimap session is not ready."); + } + + List added = new ArrayList<>(2); + ResourceKey currentDim = mc.level.dimension(); + boolean overworld = currentDim == Level.OVERWORLD; + boolean nether = currentDim == Level.NETHER; + + if (dualDefault && overworld) { + addToWorld(session, Level.OVERWORLD, x, y, z, name, "overworld", added); + addToWorld(session, Level.NETHER, XaeroWaypointStore.netherCoord(x), y, XaeroWaypointStore.netherCoord(z), name, "nether", added); + return new XaeroWaypointStore.RuntimeAddResult(true, + "Added dual Xaero marker: " + XaeroWaypointStore.format(x, y, z) + + " / Nether " + XaeroWaypointStore.format(XaeroWaypointStore.netherCoord(x), y, XaeroWaypointStore.netherCoord(z)) + ".", + List.copyOf(added)); + } + if (dualDefault && nether) { + addToWorld(session, Level.NETHER, x, y, z, name, "nether", added); + addToWorld(session, Level.OVERWORLD, x * 8, y, z * 8, name, "overworld", added); + return new XaeroWaypointStore.RuntimeAddResult(true, + "Added dual Xaero marker: Nether " + XaeroWaypointStore.format(x, y, z) + + " / Overworld " + XaeroWaypointStore.format(x * 8, y, z * 8) + ".", + List.copyOf(added)); + } + + addToWorld(session, currentDim, x, y, z, name, XaeroWaypointStore.dimensionId(currentDim.identifier()), added); + return new XaeroWaypointStore.RuntimeAddResult(true, + "Added Xaero marker: " + XaeroWaypointStore.format(x, y, z) + ".", + List.copyOf(added)); + } + + static boolean remove(XaeroWaypointStore.Marker marker) { + if (!(marker.waypointSetHandle() instanceof WaypointSet set) + || !(marker.waypointHandle() instanceof Waypoint waypoint)) { + return true; + } + set.remove(waypoint); + markChanged(); + return true; + } + + static boolean exists(XaeroWaypointStore.Marker marker) { + if (!(marker.waypointSetHandle() instanceof WaypointSet set) + || !(marker.waypointHandle() instanceof Waypoint waypoint)) { + return false; + } + for (Waypoint existing : set.getWaypoints()) { + if (existing == waypoint) { + return true; + } + } + return false; + } + + private static void addToWorld(MinimapSession session, + ResourceKey dimension, + int x, + int y, + int z, + String name, + String kind, + List out) { + MinimapWorld world = resolveWorld(session, dimension); + if (world == null) { + throw new IllegalStateException("Could not resolve Xaero world for " + dimension.identifier()); + } + + WaypointSet set = currentSet(world); + Waypoint waypoint = new Waypoint( + x, + y, + z, + XaeroWaypointStore.displayName(name, kind), + XaeroWaypointStore.DEFAULT_SYMBOL, + WaypointColor.GOLD, + WaypointPurpose.NORMAL, + true, + true + ); + waypoint.setTemporary(true); + waypoint.setYIncluded(true); + waypoint.setDisabled(false); + set.add(waypoint); + out.add(XaeroWaypointStore.createMarker( + XaeroWaypointStore.dimensionId(dimension.identifier()), + x, + y, + z, + waypoint.getName(), + waypoint.getSymbol(), + set.getName(), + kind, + set, + waypoint + )); + markChanged(); + } + + private static WaypointSet currentSet(MinimapWorld world) { + WaypointSet set = world.getCurrentWaypointSet(); + if (set != null) { + return set; + } + String setId = world.getCurrentWaypointSetId(); + if (setId == null || setId.isBlank()) { + setId = XaeroWaypointStore.SET; + world.setCurrentWaypointSetId(setId); + } + set = world.getWaypointSet(setId); + if (set == null) { + world.addWaypointSet(setId); + set = world.getWaypointSet(setId); + } + if (set == null) { + throw new IllegalStateException("Could not resolve Xaero waypoint set."); + } + return set; + } + + private static MinimapWorld resolveWorld(MinimapSession session, ResourceKey dimension) { + Minecraft mc = Minecraft.getInstance(); + MinimapWorldManager manager = session.getWorldManager(); + if (mc != null && mc.level != null && mc.level.dimension() == dimension) { + return manager.getCurrentWorld(); + } + + XaeroPath rootPath = session.getWorldState().getCurrentRootContainerPath(); + XaeroPath currentWorldPath = session.getWorldState().getCurrentWorldPath(); + if (rootPath == null || currentWorldPath == null) { + return manager.getCurrentWorld(); + } + + String dimensionNode = session.getDimensionHelper().getDimensionDirectoryName(dimension); + if (dimensionNode == null || dimensionNode.isBlank()) { + return null; + } + + String worldNode = null; + try { + boolean worldMap = HudMod.INSTANCE != null + && HudMod.INSTANCE.getSupportMods() != null + && HudMod.INSTANCE.getSupportMods().worldmap(); + worldNode = session.getWorldStateUpdater().getPotentialWorldNode(dimension, worldMap); + } catch (Throwable ignored) { + // Fallback below keeps the marker in the same multiworld node as the current dimension. + } + if (worldNode == null || worldNode.isBlank()) { + worldNode = currentWorldPath.getLastNode(); + } + + XaeroPath worldPath = rootPath.resolve(dimensionNode).resolve(worldNode); + MinimapWorld world = manager.getWorld(worldPath); + if (world != null) { + world.setDimId(dimension); + } + return world; + } + + private static MinimapSession currentSession() { + Object session = BuiltInHudModules.MINIMAP.getCurrentSession(); + return session instanceof MinimapSession minimapSession ? minimapSession : null; + } + + private static void markChanged() { + MinimapSession session = currentSession(); + if (session != null && session.getWaypointSession() != null) { + session.getWaypointSession().setSetChangedTime(System.currentTimeMillis()); + } + requestWorldMapRefresh(); + } + + private static void requestWorldMapRefresh() { + try { + Class supportMods = Class.forName("xaero.map.mods.SupportMods"); + Object support = supportMods.getField("xaeroMinimap").get(null); + if (support != null) { + support.getClass().getMethod("requestWaypointsRefresh").invoke(support); + } + } catch (Throwable ignored) { + // World Map is optional; minimap hover/list deletion works through the real Xaero set without it. + } + } + + private static XaeroWaypointStore.RuntimeAddResult fail(String message) { + return new XaeroWaypointStore.RuntimeAddResult(false, message, List.of()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointStore.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointStore.java new file mode 100644 index 0000000..131b858 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/compat/xaero/XaeroWaypointStore.java @@ -0,0 +1,249 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.compat.xaero; + +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.Level; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +public enum XaeroWaypointStore { + ; + + public static final String SET = "combatant"; + public static final String DEFAULT_NAME = "LoVisual"; + public static final String DEFAULT_SYMBOL = "C"; + + private static final AtomicLong NEXT_ID = new AtomicLong(1L); + private static final CopyOnWriteArrayList MARKERS = new CopyOnWriteArrayList<>(); + + public static AddResult addFromCurrentDimension(int x, int y, int z, String name, boolean dualDefault) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.level == null) { + return new AddResult(false, 0, "No client world is loaded."); + } + + String safeName = sanitizeName(name); + try { + RuntimeAddResult runtime = XaeroWaypointRuntime.addFromCurrentDimension(x, y, z, safeName, dualDefault); + if (!runtime.success()) { + return new AddResult(false, 0, runtime.message()); + } + MARKERS.addAll(runtime.markers()); + return new AddResult(true, runtime.markers().size(), runtime.message()); + } catch (NoClassDefFoundError | ExceptionInInitializerError error) { + return new AddResult(false, 0, "Xaero waypoint runtime is not available."); + } catch (Throwable throwable) { + return new AddResult(false, 0, "Failed to add Xaero marker: " + throwable.getClass().getSimpleName() + "."); + } + } + + public static List currentDimensionMarkers() { + pruneMissing(); + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.level == null || MARKERS.isEmpty()) return List.of(); + String dim = dimensionId(mc.level.dimension().identifier()); + List out = new ArrayList<>(); + for (Marker marker : MARKERS) { + if (marker.dimensionId().equals(dim)) { + out.add(marker); + } + } + return out; + } + + public static List markers() { + pruneMissing(); + return List.copyOf(MARKERS); + } + + public static RemoveResult remove(String selector) { + pruneMissing(); + if (MARKERS.isEmpty()) { + return new RemoveResult(true, 0, "No LoVisual Xaero markers to remove."); + } + + List targets = select(selector); + if (targets.isEmpty()) { + return new RemoveResult(false, 0, "No matching LoVisual Xaero marker: " + selector + "."); + } + + int removed = 0; + for (Marker marker : targets) { + if (removeMarker(marker)) { + removed++; + } + } + if (removed == 0) { + return new RemoveResult(false, 0, "No LoVisual Xaero markers were removed."); + } + return new RemoveResult(true, removed, "Removed Xaero markers: " + removed + "."); + } + + public static int clear() { + return remove(null).count(); + } + + static Marker createMarker(String dimensionId, + int x, + int y, + int z, + String name, + String symbol, + String set, + String kind, + Object waypointSetHandle, + Object waypointHandle) { + return new Marker( + NEXT_ID.getAndIncrement(), + dimensionId, + x, + y, + z, + name, + symbol, + set, + kind, + System.currentTimeMillis(), + waypointSetHandle, + waypointHandle + ); + } + + private static List select(String selector) { + String normalized = selector == null ? "" : selector.trim(); + if (normalized.isEmpty() + || "all".equalsIgnoreCase(normalized) + || "*".equals(normalized)) { + return new ArrayList<>(MARKERS); + } + + Integer index = parsePositiveInt(normalized); + if (index != null) { + List snapshot = markers(); + int zeroIndex = index - 1; + if (zeroIndex >= 0 && zeroIndex < snapshot.size()) { + return List.of(snapshot.get(zeroIndex)); + } + return List.of(); + } + + String lower = normalized.toLowerCase(Locale.ROOT); + List out = new ArrayList<>(); + for (Marker marker : MARKERS) { + if (marker.name().toLowerCase(Locale.ROOT).contains(lower) + || marker.kind().toLowerCase(Locale.ROOT).contains(lower) + || marker.dimensionId().toLowerCase(Locale.ROOT).contains(lower)) { + out.add(marker); + } + } + return out; + } + + private static boolean removeMarker(Marker marker) { + boolean removedFromXaero = true; + try { + removedFromXaero = XaeroWaypointRuntime.remove(marker); + } catch (NoClassDefFoundError | ExceptionInInitializerError ignored) { + removedFromXaero = true; + } catch (Throwable ignored) { + removedFromXaero = false; + } + boolean removedFromStore = MARKERS.remove(marker); + return removedFromStore && removedFromXaero; + } + + private static void pruneMissing() { + if (MARKERS.isEmpty()) return; + try { + Iterator iterator = MARKERS.iterator(); + while (iterator.hasNext()) { + Marker marker = iterator.next(); + if (!XaeroWaypointRuntime.exists(marker)) { + MARKERS.remove(marker); + } + } + } catch (NoClassDefFoundError | ExceptionInInitializerError ignored) { + MARKERS.clear(); + } catch (Throwable ignored) { + // Keep the in-memory index if Xaero is mid-session reload or unavailable for a tick. + } + } + + static int netherCoord(int overworldCoord) { + return (int) Math.round(overworldCoord / 8.0); + } + + static String dimensionId(Identifier id) { + return id == null ? "" : id.toString(); + } + + static String sanitizeName(String name) { + if (name == null || name.isBlank()) return DEFAULT_NAME; + String trimmed = name.trim(); + return trimmed.length() > 40 ? trimmed.substring(0, 40) : trimmed; + } + + static String displayName(String name, String kind) { + String safeName = sanitizeName(name); + if (kind == null || kind.isBlank() || kind.contains(":")) return safeName; + return safeName + " " + kind; + } + + static String format(int x, int y, int z) { + return String.format(Locale.ROOT, "%d %d %d", x, y, z); + } + + private static Integer parsePositiveInt(String value) { + try { + int parsed = Integer.parseInt(value); + return parsed <= 0 ? null : parsed; + } catch (NumberFormatException ignored) { + return null; + } + } + + static boolean isOverworld() { + Minecraft mc = Minecraft.getInstance(); + return mc != null && mc.level != null && mc.level.dimension() == Level.OVERWORLD; + } + + static boolean isNether() { + Minecraft mc = Minecraft.getInstance(); + return mc != null && mc.level != null && mc.level.dimension() == Level.NETHER; + } + + public record Marker(long id, + String dimensionId, + int x, + int y, + int z, + String name, + String symbol, + String set, + String kind, + long createdAtMs, + Object waypointSetHandle, + Object waypointHandle) { + } + + public record AddResult(boolean success, int count, String message) { + } + + public record RemoveResult(boolean success, int count, String message) { + } + + static record RuntimeAddResult(boolean success, String message, List markers) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Event.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Event.java new file mode 100644 index 0000000..1f7e4dd --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Event.java @@ -0,0 +1,21 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class Event { + private boolean cancelled; + + public void cancel() { + cancelled = true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventBus.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventBus.java new file mode 100644 index 0000000..4dde1f6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventBus.java @@ -0,0 +1,263 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events; + +import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.runtime.RuntimeGate; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +public final class EventBus { + private static final Subscriber[] EMPTY_SUBSCRIBERS = new Subscriber[0]; + private static final Comparator PRIORITY = + Comparator.comparingInt((Subscriber sub) -> sub.priority).reversed(); + + private final Object lock = new Object(); + + private final Reference2ObjectOpenHashMap, Subscriber[]> exactHandlers = new Reference2ObjectOpenHashMap<>(); + private final Reference2ObjectOpenHashMap, Subscriber[]> dispatchCache = new Reference2ObjectOpenHashMap<>(); + private final Reference2ObjectOpenHashMap ownerIndex = new Reference2ObjectOpenHashMap<>(); + + private static Subscriber[] append(Subscriber[] old, Subscriber sub) { + Subscriber[] next = Arrays.copyOf(old, old.length + 1); + next[old.length] = sub; + return next; + } + + private static Subscriber[] remove(Subscriber[] old, Subscriber sub) { + int index = -1; + for (int i = 0; i < old.length; i++) { + if (old[i] == sub) { + index = i; + break; + } + } + + if (index < 0) return old; + if (old.length == 1) return EMPTY_SUBSCRIBERS; + + Subscriber[] next = new Subscriber[old.length - 1]; + System.arraycopy(old, 0, next, 0, index); + System.arraycopy(old, index + 1, next, index, old.length - index - 1); + return next; + } + + private static void sortSubscribers(Subscriber[] subscribers) { + Arrays.sort(subscribers, PRIORITY); + } + + public void register(Object listener) { + if (listener instanceof Module module) { + registerOwned(module, listener); + } else { + registerOwned(null, listener); + } + } + + public void registerOwned(Module gateModule, Object listener) { + if (listener == null) return; + + synchronized (lock) { + if (ownerIndex.containsKey(listener)) return; + + Subscriber[] subs = scan(listener, gateModule); + if (subs.length == 0) return; + + ownerIndex.put(listener, subs); + for (Subscriber sub : subs) { + Subscriber[] old = exactHandlers.get(sub.eventType); + if (old == null) old = EMPTY_SUBSCRIBERS; + Subscriber[] next = append(old, sub); + sortSubscribers(next); + exactHandlers.put(sub.eventType, next); + } + + dispatchCache.clear(); + } + } + + public void unregister(Object listener) { + if (listener == null) return; + + synchronized (lock) { + Subscriber[] subs = ownerIndex.remove(listener); + if (subs == null || subs.length == 0) return; + + for (Subscriber sub : subs) { + Subscriber[] old = exactHandlers.get(sub.eventType); + if (old == null || old.length == 0) continue; + + Subscriber[] next = remove(old, sub); + if (next.length == 0) { + exactHandlers.remove(sub.eventType); + } else { + exactHandlers.put(sub.eventType, next); + } + } + + dispatchCache.clear(); + } + } + + public boolean hasListeners(Class eventType) { + return subscribersFor(eventType).length != 0; + } + + public T post(T event) { + if (event == null) return null; + if (!RuntimeGate.canRunClientLogic()) return event; + + Subscriber[] subscribers = subscribersFor(event.getClass()); + for (Subscriber sub : subscribers) { + Module gate = sub.gateModule; + if (gate != null && !gate.isEnabled()) continue; + + try { + sub.invoker.invoke(event); + } catch (Throwable t) { + DebugLog.error("Event handler failed: %s", t, sub.describe()); + } + } + + return event; + } + + public void clear() { + synchronized (lock) { + exactHandlers.clear(); + dispatchCache.clear(); + ownerIndex.clear(); + } + } + + public int listenerCount() { + synchronized (lock) { + return ownerIndex.size(); + } + } + + private Subscriber[] scan(Object listener, Module gateModule) { + List out = new ArrayList<>(); + Class cls = listener.getClass(); + + for (Method method : cls.getDeclaredMethods()) { + if (!method.isAnnotationPresent(EventHandler.class)) continue; + + Class[] params = method.getParameterTypes(); + if (params.length != 1) continue; + if (!Event.class.isAssignableFrom(params[0])) continue; + + EventHandler meta = method.getAnnotation(EventHandler.class); + method.setAccessible(true); + + @SuppressWarnings("unchecked") + Class eventType = (Class) params[0]; + + EventInvoker invoker = createInvoker(listener, method); + out.add(new Subscriber(listener, gateModule, method, invoker, eventType, meta.priority())); + } + + return out.toArray(Subscriber[]::new); + } + + private EventInvoker createInvoker(Object listener, Method method) { + method.setAccessible(true); + try { + MethodHandles.Lookup lookup = MethodHandles.lookup(); + MethodHandle handle = lookup.unreflect(method).bindTo(listener); + return handle::invoke; + } catch (Throwable t) { + DebugLog.error("Failed to create MethodHandle event invoker, falling back to reflection: %s", t, + listener.getClass().getName() + "#" + method.getName()); + return event -> method.invoke(listener, event); + } + } + + private Subscriber[] subscribersFor(Class eventType) { + if (eventType == null) return EMPTY_SUBSCRIBERS; + + synchronized (lock) { + Subscriber[] cached = dispatchCache.get(eventType); + if (cached != null) return cached; + + Subscriber[] built = buildFlattenedSubscribers(eventType); + dispatchCache.put(eventType, built); + return built; + } + } + + private Subscriber[] buildFlattenedSubscribers(Class eventType) { + int total = 0; + + Class type = eventType; + while (type != null && Event.class.isAssignableFrom(type)) { + Subscriber[] exact = exactHandlers.get(type); + if (exact != null) total += exact.length; + type = type.getSuperclass(); + } + + if (total == 0) return EMPTY_SUBSCRIBERS; + + Subscriber[] out = new Subscriber[total]; + int offset = 0; + + type = eventType; + while (type != null && Event.class.isAssignableFrom(type)) { + Subscriber[] exact = exactHandlers.get(type); + if (exact != null && exact.length > 0) { + System.arraycopy(exact, 0, out, offset, exact.length); + offset += exact.length; + } + type = type.getSuperclass(); + } + + return out; + } + + @FunctionalInterface + private interface EventInvoker { + void invoke(Event event) throws Throwable; + } + + private static final class Subscriber { + final Object owner; + final Module gateModule; + final Method method; + final EventInvoker invoker; + final Class eventType; + final int priority; + final String description; + + Subscriber(Object owner, + Module gateModule, + Method method, + EventInvoker invoker, + Class eventType, + int priority) { + this.owner = owner; + this.gateModule = gateModule; + this.method = method; + this.invoker = invoker; + this.eventType = eventType; + this.priority = priority; + this.description = owner.getClass().getName() + "#" + method.getName(); + } + + String describe() { + return description; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventHandler.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventHandler.java new file mode 100644 index 0000000..bc76170 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/EventHandler.java @@ -0,0 +1,20 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@UsedImplicitly +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface EventHandler { + int priority() default 0; +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Events.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Events.java new file mode 100644 index 0000000..c4712cc --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/Events.java @@ -0,0 +1,14 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events; + +public enum Events { + ; + public static final EventBus BUS = new EventBus(); + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/UsedImplicitly.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/UsedImplicitly.java new file mode 100644 index 0000000..c425489 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/UsedImplicitly.java @@ -0,0 +1,31 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks code that is intentionally reached through reflection, Fabric entrypoints, + * addon API calls, event dispatch, serialization, or other non-direct call paths. + * + *

This is a source-level documentation and IntelliJ IDEA inspection hint. It is + * not a runtime contract and should not be used for behavior checks.

+ */ +@Retention(RetentionPolicy.CLASS) +@Target({ + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.FIELD, + ElementType.METHOD, + ElementType.TYPE +}) +public @interface UsedImplicitly { +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/AttackEntityEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/AttackEntityEvent.java new file mode 100644 index 0000000..18ae4bf --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/AttackEntityEvent.java @@ -0,0 +1,25 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import dev.loki.lovisual.events.Event; + +@Getter +public final class AttackEntityEvent extends Event { + private final Player player; + private final Entity target; + + public AttackEntityEvent(Player player, Entity target) { + this.player = player; + this.target = target; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/BlinkPacketEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/BlinkPacketEvent.java new file mode 100644 index 0000000..8918e30 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/BlinkPacketEvent.java @@ -0,0 +1,48 @@ +/* + * This file is part of the Combatant Client distribution. + * Combatant modifications copyright (c) 2026 pivosos2007. + * + * Portions of this file are based on LiquidBounce + * (https://github.com/CCBlueX/LiquidBounce). + * Copyright (c) 2015-2026 CCBlueX. + * + * LiquidBounce portions are licensed under GPLv3-or-later. + * Combatant modifications are licensed under GPLv3. + * See THIRD_PARTY_NOTICES.md for details. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.network.protocol.Packet; +import dev.loki.lovisual.events.Event; +import dev.loki.lovisual.util.network.BlinkManager; +import dev.loki.lovisual.util.network.TransferOrigin; + +/** + * Adapted from LiquidBounce's BlinkPacketEvent. + */ +public class BlinkPacketEvent extends Event { + private final Packet packet; + @Getter + private final TransferOrigin origin; + @Getter + private BlinkManager.Action action = BlinkManager.Action.FLUSH; + + public BlinkPacketEvent(Packet packet, TransferOrigin origin) { + this.packet = packet; + this.origin = origin; + } + + @SuppressWarnings("unchecked") + public > T getPacket() { + return (T) packet; + } + + public void setAction(BlinkManager.Action action) { + if (action == null || this.action == action || this.action.getPriority() >= action.getPriority()) { + return; + } + this.action = action; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CombatProtocolBossbarEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CombatProtocolBossbarEvent.java new file mode 100644 index 0000000..d04d994 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CombatProtocolBossbarEvent.java @@ -0,0 +1,26 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; + +import java.util.List; + +public class CombatProtocolBossbarEvent extends Event { + public final List names; + public final long timeMs; + + public CombatProtocolBossbarEvent(List names) { + this(names, System.currentTimeMillis()); + } + + public CombatProtocolBossbarEvent(List names, long timeMs) { + this.names = names == null ? List.of() : List.copyOf(names); + this.timeMs = timeMs; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CrosshairTargetUpdateEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CrosshairTargetUpdateEvent.java new file mode 100644 index 0000000..93fc262 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/CrosshairTargetUpdateEvent.java @@ -0,0 +1,30 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.HitResult; +import dev.loki.lovisual.events.Event; + +@Getter +public final class CrosshairTargetUpdateEvent extends Event { + private final float tickDelta; + @Setter + private HitResult hitResult; + @Setter + private Entity targetedEntity; + + public CrosshairTargetUpdateEvent(float tickDelta, HitResult hitResult, Entity targetedEntity) { + this.tickDelta = tickDelta; + this.hitResult = hitResult; + this.targetedEntity = targetedEntity; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventBreakBlock.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventBreakBlock.java new file mode 100644 index 0000000..172af19 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventBreakBlock.java @@ -0,0 +1,22 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.core.BlockPos; +import dev.loki.lovisual.events.Event; + +@Getter +public class EventBreakBlock extends Event { + private final BlockPos pos; + + public EventBreakBlock(BlockPos pos) { + this.pos = pos; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventCollision.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventCollision.java new file mode 100644 index 0000000..7f97ebb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventCollision.java @@ -0,0 +1,27 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; +import dev.loki.lovisual.events.Event; + +@Getter +public class EventCollision extends Event { + private final BlockPos pos; + @Setter + private BlockState state; + + public EventCollision(BlockState state, BlockPos pos) { + this.state = state; + this.pos = pos; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPostSync.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPostSync.java new file mode 100644 index 0000000..224651a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPostSync.java @@ -0,0 +1,15 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; + +public class EventPostSync extends Event { + public EventPostSync() { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPushOutOfBlocks.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPushOutOfBlocks.java new file mode 100644 index 0000000..bf63e22 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventPushOutOfBlocks.java @@ -0,0 +1,23 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; + +@Getter +public class EventPushOutOfBlocks extends Event { + private final double x; + private final double z; + + public EventPushOutOfBlocks(double x, double z) { + this.x = x; + this.z = z; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventSync.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventSync.java new file mode 100644 index 0000000..225e795 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventSync.java @@ -0,0 +1,69 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; + +public class EventSync extends Event { + @Getter + private final float originalYaw; + @Getter + private final float originalPitch; + @Getter + private float yaw; + @Getter + private float pitch; + private boolean rotationOverride; + @Getter + private boolean silentRotation; + @Getter + private Runnable postAction; + + public EventSync(float yaw, float pitch) { + this.originalYaw = yaw; + this.originalPitch = pitch; + this.yaw = yaw; + this.pitch = pitch; + } + + public void setYaw(float yaw) { + this.yaw = yaw; + this.rotationOverride = true; + } + + public void setPitch(float pitch) { + this.pitch = pitch; + this.rotationOverride = true; + } + + public void setRotation(float yaw, float pitch, boolean silent) { + this.yaw = yaw; + this.pitch = pitch; + this.rotationOverride = true; + this.silentRotation = silent; + } + + public boolean hasRotationOverride() { + return rotationOverride; + } + + public void addPostAction(Runnable r) { + if (r == null) return; + if (postAction == null) { + postAction = r; + } else { + Runnable prev = postAction; + postAction = () -> { + prev.run(); + r.run(); + }; + } + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventTargetChanged.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventTargetChanged.java new file mode 100644 index 0000000..62de841 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/EventTargetChanged.java @@ -0,0 +1,32 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import net.minecraft.world.entity.LivingEntity; +import dev.loki.lovisual.events.Event; +import dev.loki.lovisual.util.target.TargetManager; + +/** + * Fired when the centralized target changes. + */ +public class EventTargetChanged extends Event { + public final LivingEntity previous; + public final TargetManager.Source previousSource; + public final LivingEntity current; + public final TargetManager.Source currentSource; + + public EventTargetChanged(LivingEntity previous, + TargetManager.Source previousSource, + LivingEntity current, + TargetManager.Source currentSource) { + this.previous = previous; + this.previousSource = previousSource; + this.current = current; + this.currentSource = currentSource; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/FireworkEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/FireworkEvent.java new file mode 100644 index 0000000..d8cde6e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/FireworkEvent.java @@ -0,0 +1,24 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.world.phys.Vec3; +import dev.loki.lovisual.events.Event; + +@Setter +@Getter +public final class FireworkEvent extends Event { + private Vec3 vector; + + public FireworkEvent(Vec3 vector) { + this.vector = vector; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/GameTickEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/GameTickEvent.java new file mode 100644 index 0000000..f57bca8 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/GameTickEvent.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; + +/** + * Fired at the start of each client tick. + */ +public final class GameTickEvent extends Event { +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/I18nPreflightCollectEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/I18nPreflightCollectEvent.java new file mode 100644 index 0000000..d5ee4d5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/I18nPreflightCollectEvent.java @@ -0,0 +1,94 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.events.Event; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Collects setting-backed i18n keys after language resources are loaded. + */ +public final class I18nPreflightCollectEvent extends Event { + private final String reason; + private final ArrayList entries = new ArrayList<>(); + + public I18nPreflightCollectEvent(String reason) { + this.reason = reason != null && !reason.isBlank() ? reason : "i18n preflight"; + } + + public String reason() { + return reason; + } + + public I18nPreflightCollectEvent setting(Setting setting) { + return setting("settings", setting); + } + + public I18nPreflightCollectEvent setting(String path, Setting setting) { + if (setting != null) { + entries.add(new Entry(normalizePath(path), setting)); + } + return this; + } + + public I18nPreflightCollectEvent settings(Iterable values) { + return settings("settings", values); + } + + public I18nPreflightCollectEvent settings(String path, Iterable values) { + if (values == null) return this; + for (Setting setting : values) { + setting(path, setting); + } + return this; + } + + public I18nPreflightCollectEvent settingDefs(SettingOwner owner, Iterable defs) { + return settingDefs(owner != null ? owner.name() : "settings", owner, defs); + } + + public I18nPreflightCollectEvent settingDefs(String path, SettingOwner owner, Iterable defs) { + if (owner == null || defs == null) return this; + for (SettingDef def : defs) { + Setting setting = SettingFactory.fromDef(def); + if (setting == null) continue; + setting.setParent(owner); + setting(path, setting); + } + return this; + } + + public List entries() { + return Collections.unmodifiableList(entries); + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + private static String normalizePath(String path) { + if (path == null || path.isBlank()) return "settings"; + String normalized = path.trim().replace('\\', '/'); + while (normalized.contains("//")) { + normalized = normalized.replace("//", "/"); + } + if (normalized.startsWith("/")) normalized = normalized.substring(1); + if (normalized.endsWith("/")) normalized = normalized.substring(0, normalized.length() - 1); + return normalized.isBlank() ? "settings" : normalized; + } + + public record Entry(String path, Setting setting) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeyInputEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeyInputEvent.java new file mode 100644 index 0000000..68eb243 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeyInputEvent.java @@ -0,0 +1,24 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.client.input.KeyEvent; +import dev.loki.lovisual.events.Event; + +@Getter +public final class KeyInputEvent extends Event { + private final int action; + private final KeyEvent input; + + public KeyInputEvent(int action, KeyEvent input) { + this.action = action; + this.input = input; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeybindIsPressedEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeybindIsPressedEvent.java new file mode 100644 index 0000000..eefb91d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/KeybindIsPressedEvent.java @@ -0,0 +1,27 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.client.KeyMapping; +import dev.loki.lovisual.events.Event; + +@Getter +public final class KeybindIsPressedEvent extends Event { + + private final KeyMapping keyBinding; + @Setter + private boolean pressed; + + public KeybindIsPressedEvent(KeyMapping keyBinding, boolean pressed) { + this.keyBinding = keyBinding; + this.pressed = pressed; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapEvent.java new file mode 100644 index 0000000..b0d4670 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapEvent.java @@ -0,0 +1,34 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class LightmapEvent extends Event { + private final float originalAmbient; + @Setter + private float ambientLight; + + public LightmapEvent(float ambientLight) { + this.originalAmbient = ambientLight; + this.ambientLight = ambientLight; + } + + public void raiseAmbientLight(float ambientLight) { + if (ambientLight > this.ambientLight) { + this.ambientLight = ambientLight; + } + } + + public boolean isModified() { + return Math.abs(ambientLight - originalAmbient) > 1.0e-4f; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapModifyEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapModifyEvent.java new file mode 100644 index 0000000..6f112ef --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/LightmapModifyEvent.java @@ -0,0 +1,81 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.client.renderer.state.LightmapRenderState; +import net.minecraft.util.Mth; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import dev.loki.lovisual.events.Event; + +public class LightmapModifyEvent extends Event { + private static final float EPSILON = 1.0e-4f; + + private final LightmapRenderState state; + private final float originalBrightness; + @Getter + private boolean modified; + + public LightmapModifyEvent(LightmapRenderState state) { + this.state = state; + this.originalBrightness = state != null ? state.brightness : 0.0f; + } + + public LightmapRenderState state() { + return state; + } + + public float originalBrightness() { + return originalBrightness; + } + + public void raiseMinimumLight(float minLight) { + if (state == null) return; + + float strength = Mth.clamp(minLight, 0.0f, 1.0f); + if (strength <= EPSILON) return; + + state.needsUpdate = true; + + // Do not emulate FullBright by forcing brightness/night vision to 1.0. + // In the 1.21+ UBO lightmap path that behaves like global exposure and blows out the scene. + float exposureFloor = 0.08f + 0.18f * strength; + state.brightness = Math.max(state.brightness, exposureFloor); + state.nightVisionEffectIntensity = Math.max(state.nightVisionEffectIntensity, 0.10f * strength); + + // Lift the generated lightmap itself, but keep vanilla color response instead of whitening it. + state.skyFactor = Math.max(state.skyFactor, 0.55f + 0.35f * strength); + state.blockFactor = Math.max(state.blockFactor, 1.4f + 0.25f * strength); + + // FullBright must not be attenuated by darkness/boss-world darkening. + float attenuation = 1.0f - strength; + state.darknessEffectScale *= attenuation; + state.bossOverlayWorldDarkening *= attenuation; + + float colorFloor = 0.10f + 0.28f * strength; + state.blockLightTint = raiseColorFloor(state.blockLightTint, colorFloor); + state.skyLightColor = raiseColorFloor(state.skyLightColor, colorFloor); + state.ambientColor = raiseColorFloor(state.ambientColor, colorFloor); + state.nightVisionColor = raiseColorFloor(state.nightVisionColor, colorFloor); + + modified = true; + } + + private static Vector3fc raiseColorFloor(Vector3fc color, float floor) { + float min = Mth.clamp(floor, 0.0f, 1.0f); + if (color == null) { + return new Vector3f(min, min, min); + } + return new Vector3f( + Math.max(color.x(), min), + Math.max(color.y(), min), + Math.max(color.z(), min) + ); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/MovementInputEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/MovementInputEvent.java new file mode 100644 index 0000000..73491e6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/MovementInputEvent.java @@ -0,0 +1,45 @@ +/* + * This file is part of the Combatant Client distribution. + * Combatant modifications copyright (c) 2026 pivosos2007. + * + * Portions of this file are based on LiquidBounce + * (https://github.com/CCBlueX/LiquidBounce). + * Copyright (c) 2015-2026 CCBlueX. + * + * LiquidBounce portions are licensed under GPLv3-or-later. + * Combatant modifications are licensed under GPLv3. + * See THIRD_PARTY_NOTICES.md for details. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.world.entity.player.Input; +import dev.loki.lovisual.events.Event; + +@Setter +@Getter +public final class MovementInputEvent extends Event { + private boolean forward; + private boolean backward; + private boolean left; + private boolean right; + private boolean jump; + private boolean sneak; + private boolean sprint; + + public MovementInputEvent(Input input) { + this.forward = input.forward(); + this.backward = input.backward(); + this.left = input.left(); + this.right = input.right(); + this.jump = input.jump(); + this.sneak = input.shift(); + this.sprint = input.sprint(); + } + + public Input toPlayerInput() { + return new Input(forward, backward, left, right, jump, sneak, sprint); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PacketEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PacketEvent.java new file mode 100644 index 0000000..5d6bab9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PacketEvent.java @@ -0,0 +1,76 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.network.protocol.Packet; +import dev.loki.lovisual.events.Event; +import dev.loki.lovisual.util.network.TransferOrigin; + +public class PacketEvent extends Event { + private final Packet packet; + @Getter + private final TransferOrigin origin; + @Getter + private final boolean original; + + public PacketEvent(Packet packet) { + this(packet, null, true); + } + + public PacketEvent(Packet packet, TransferOrigin origin, boolean original) { + this.packet = packet; + this.origin = origin; + this.original = original; + } + + @SuppressWarnings("unchecked") + public > T getPacket() { + return (T) this.packet; + } + + public static class Send extends PacketEvent { + public Send(Packet packet) { + super(packet, TransferOrigin.OUTGOING, true); + } + + public Send(Packet packet, boolean original) { + super(packet, TransferOrigin.OUTGOING, original); + } + } + + public static class Receive extends PacketEvent { + public Receive(Packet packet) { + super(packet, TransferOrigin.INCOMING, true); + } + + public Receive(Packet packet, boolean original) { + super(packet, TransferOrigin.INCOMING, original); + } + } + + public static class SendPost extends PacketEvent { + public SendPost(Packet packet) { + super(packet, TransferOrigin.OUTGOING, true); + } + @SuppressWarnings("unused") + public SendPost(Packet packet, boolean original) { + super(packet, TransferOrigin.OUTGOING, original); + } + } + + public static class ReceivePost extends PacketEvent { + public ReceivePost(Packet packet) { + super(packet, TransferOrigin.INCOMING, true); + } + @SuppressWarnings("unused") + public ReceivePost(Packet packet, boolean original) { + super(packet, TransferOrigin.INCOMING, original); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerJumpEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerJumpEvent.java new file mode 100644 index 0000000..7255976 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerJumpEvent.java @@ -0,0 +1,25 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public final class PlayerJumpEvent extends Event { + private float yaw; + private float motion; + + public PlayerJumpEvent(float yaw, float motion) { + this.yaw = yaw; + this.motion = motion; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerMoveEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerMoveEvent.java new file mode 100644 index 0000000..bcf6a85 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerMoveEvent.java @@ -0,0 +1,33 @@ +/* + * This file is part of the Combatant Client distribution. + * Combatant modifications copyright (c) 2026 pivosos2007. + * + * Portions of this file are based on, adapted from, or implemented + * with reference to Meteor Client + * (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + * + * Licensed under the GNU General Public License v3.0. + * See THIRD_PARTY_NOTICES.md for details. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.phys.Vec3; +import dev.loki.lovisual.events.Event; + +@Getter +public class PlayerMoveEvent extends Event { + private final MoverType type; + @Setter + private Vec3 movement; + + public PlayerMoveEvent(MoverType type, Vec3 movement) { + this.type = type; + this.movement = movement; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerSafeWalkEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerSafeWalkEvent.java new file mode 100644 index 0000000..c3dc5e1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerSafeWalkEvent.java @@ -0,0 +1,19 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class PlayerSafeWalkEvent extends Event { + private boolean safeWalk; + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepEvent.java new file mode 100644 index 0000000..794f737 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepEvent.java @@ -0,0 +1,23 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public final class PlayerStepEvent extends Event { + private float height; + + public PlayerStepEvent(float height) { + this.height = height; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepSuccessEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepSuccessEvent.java new file mode 100644 index 0000000..19672d0 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerStepSuccessEvent.java @@ -0,0 +1,22 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import net.minecraft.world.phys.Vec3; +import dev.loki.lovisual.events.Event; + +@Getter +public final class PlayerStepSuccessEvent extends Event { + private final Vec3 adjustedVec; + + public PlayerStepSuccessEvent(Vec3 adjustedVec) { + this.adjustedVec = adjustedVec; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerVelocityStrafe.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerVelocityStrafe.java new file mode 100644 index 0000000..f6f5ca8 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PlayerVelocityStrafe.java @@ -0,0 +1,31 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import lombok.Getter; +import lombok.Setter; +import net.minecraft.world.phys.Vec3; +import dev.loki.lovisual.events.Event; + +@Getter +public class PlayerVelocityStrafe extends Event { + + private final Vec3 movementInput; + private final float speed; + private final float yaw; + @Setter + private Vec3 velocity; + + public PlayerVelocityStrafe(Vec3 movementInput, float speed, float yaw, Vec3 velocity) { + this.movementInput = movementInput; + this.speed = speed; + this.yaw = yaw; + this.velocity = velocity; + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PostPlayerUpdateEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PostPlayerUpdateEvent.java new file mode 100644 index 0000000..5322c51 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PostPlayerUpdateEvent.java @@ -0,0 +1,19 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class PostPlayerUpdateEvent extends Event { + private int iterations; + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpChatEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpChatEvent.java new file mode 100644 index 0000000..ec9f17f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpChatEvent.java @@ -0,0 +1,33 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; + +/** + * Fired when a PvP-related chat or game message is received. + */ +public class PvpChatEvent extends Event { + public final String message; + public final Source source; + public final long timeMs; + public PvpChatEvent(String message, Source source) { + this(message, source, System.currentTimeMillis()); + } + + public PvpChatEvent(String message, Source source, long timeMs) { + this.message = message; + this.source = source; + this.timeMs = timeMs; + } + + public enum Source { + GAME_MESSAGE, + CHAT_MESSAGE + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpOverlayEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpOverlayEvent.java new file mode 100644 index 0000000..2331791 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpOverlayEvent.java @@ -0,0 +1,30 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.events.Event; + +/** + * Fired when an in-game HUD overlay message is shown (action bar). + */ +public class PvpOverlayEvent extends Event { + public final Component message; + public final boolean tinted; + public final long timeMs; + + public PvpOverlayEvent(Component message, boolean tinted) { + this(message, tinted, System.currentTimeMillis()); + } + + public PvpOverlayEvent(Component message, boolean tinted, long timeMs) { + this.message = message; + this.tinted = tinted; + this.timeMs = timeMs; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpTabEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpTabEvent.java new file mode 100644 index 0000000..20f1f7e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/PvpTabEvent.java @@ -0,0 +1,35 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; + +/** + * Fired when the server updates tab-list header/footer text. + */ +public class PvpTabEvent extends Event { + public final String header; + public final String footer; + public final long timeMs; + + public PvpTabEvent(String header, String footer) { + this(header, footer, System.currentTimeMillis()); + } + + public PvpTabEvent(String header, String footer, long timeMs) { + this.header = header == null ? "" : header; + this.footer = footer == null ? "" : footer; + this.timeMs = timeMs; + } + + public String combinedText() { + if (header.isBlank()) return footer; + if (footer.isBlank()) return header; + return header + "\n" + footer; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RenderPrewarmCollectEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RenderPrewarmCollectEvent.java new file mode 100644 index 0000000..73112cc --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RenderPrewarmCollectEvent.java @@ -0,0 +1,81 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import dev.loki.lovisual.render.engine.svg.SvgRegistry; +import dev.loki.lovisual.render.engine.text.FontInfo; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Collects render resources that are known to be needed before the first GUI/HUD draw. + * Producers should add only resources implied by current runtime state/config: enabled + * modules, enabled HUD elements, and UI surfaces that can be opened immediately. + */ +public final class RenderPrewarmCollectEvent extends Event { + private final String reason; + private final LinkedHashSet fonts = new LinkedHashSet<>(); + private final LinkedHashSet svgMsdfIcons = new LinkedHashSet<>(); + + public RenderPrewarmCollectEvent(String reason) { + this.reason = reason != null ? reason : "render prewarm"; + } + + public String reason() { + return reason; + } + + public RenderPrewarmCollectEvent font(String family) { + return font(family, FontInfo.Type.Regular); + } + + public RenderPrewarmCollectEvent font(String family, FontInfo.Type type) { + if (family == null || family.isBlank()) return this; + fonts.add(new FontInfo(family.trim(), type != null ? type : FontInfo.Type.Regular)); + return this; + } + + public RenderPrewarmCollectEvent font(@Nullable FontInfo info) { + if (info != null && info.family() != null && !info.family().isBlank()) { + fonts.add(info); + } + return this; + } + + public RenderPrewarmCollectEvent svg(String nameOrPath) { + Identifier id = SvgRegistry.resolve(nameOrPath); + if (id != null) { + svgMsdfIcons.add(id); + } + return this; + } + + public RenderPrewarmCollectEvent svg(@Nullable Identifier id) { + if (id != null) { + svgMsdfIcons.add(id); + } + return this; + } + + public Set fonts() { + return Collections.unmodifiableSet(fonts); + } + + public Set svgMsdfIcons() { + return Collections.unmodifiableSet(svgMsdfIcons); + } + + public boolean isEmpty() { + return fonts.isEmpty() && svgMsdfIcons.isEmpty(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RotationUpdateEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RotationUpdateEvent.java new file mode 100644 index 0000000..4ac6841 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/RotationUpdateEvent.java @@ -0,0 +1,28 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; + +/** + * Rotation lifecycle event fired each tick in two phases. + */ +@Getter +public final class RotationUpdateEvent extends Event { + private final Type type; + + public RotationUpdateEvent(Type type) { + this.type = type == null ? Type.PRE : type; + } + + public enum Type { + PRE, + POST + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/SprintControlEvent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/SprintControlEvent.java new file mode 100644 index 0000000..8227de4 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/events/impl/SprintControlEvent.java @@ -0,0 +1,43 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.events.impl; + +import dev.loki.lovisual.events.Event; +import lombok.Getter; +import lombok.Setter; + +public class SprintControlEvent extends Event { + @Getter + private final float forward; + @Getter + private final float sideways; + @Getter + private final Source source; + @Setter + private boolean sprint; + public SprintControlEvent(float forward, float sideways, boolean sprint, Source source) { + this.forward = forward; + this.sideways = sideways; + this.sprint = sprint; + this.source = source; + } + + public boolean isMoving() { + return forward != 0.0f || sideways != 0.0f; + } + + public boolean shouldSprint() { + return sprint; + } + + public enum Source { + INPUT, + MOVEMENT_TICK, + NETWORK + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountAuthType.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountAuthType.java new file mode 100644 index 0000000..839bca6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountAuthType.java @@ -0,0 +1,35 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.account; + +public enum AccountAuthType { + OFFLINE("offline"), + MICROSOFT("microsoft"); + + private final String id; + + AccountAuthType(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static AccountAuthType byId(String raw) { + if (raw == null || raw.isBlank()) { + return OFFLINE; + } + for (AccountAuthType type : values()) { + if (type.id.equalsIgnoreCase(raw.trim()) || type.name().equalsIgnoreCase(raw.trim())) { + return type; + } + } + return OFFLINE; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountConfig.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountConfig.java new file mode 100644 index 0000000..40568df --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountConfig.java @@ -0,0 +1,473 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.account; + +import net.minecraft.resources.Identifier; +import dev.loki.lovisual.config.ConfigNameProvider; +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.config.JsonConfigObject; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.config.values.StringValue; +import dev.loki.lovisual.util.session.MicrosoftSessionResult; +import dev.loki.lovisual.util.session.SessionChanger; +import dev.loki.lovisual.util.session.microsoft.MicrosoftAuthService; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +public final class AccountConfig implements JsonConfigObject, ConfigNameProvider { + + public static final AccountConfig INSTANCE = new AccountConfig(); + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"); + + private final AccountsValue accounts = new AccountsValue("accounts"); + private final StringValue activeAccountName = new StringValue("active_name", ""); + private final StringValue activeAccountDate = new StringValue("active_date", ""); + private final StringValue activeAccountSkin = new StringValue("active_skin", ""); + private String microsoftAuthorizationRequiredName = ""; + + private AccountConfig() { + ConfigSerializer.load(this); + applyActiveSession(); + } + + public static AccountConfig get() { + return INSTANCE; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static String normalizeName(String name) { + return name == null ? "" : name.trim(); + } + + private static String nowString() { + return LocalDateTime.now().format(DATE_FORMAT); + } + + private static Identifier parseIdentifier(String raw) { + if (isBlank(raw)) { + return null; + } + try { + return Identifier.parse(raw); + } catch (Exception ignored) { + return null; + } + } + + private static UUID parseUuid(String raw) { + if (isBlank(raw)) { + return null; + } + try { + return UUID.fromString(raw.trim()); + } catch (Exception ignored) { + return null; + } + } + + public List getAccounts() { + return accounts.get(); + } + + public List getSortedAccounts() { + List sorted = new ArrayList<>(accounts.get()); + sorted.sort(Comparator + .comparing(AccountEntry::isPinned).reversed() + .thenComparingInt(AccountEntry::getOriginalIndex)); + return sorted; + } + + public AccountEntry getAccountBySortedIndex(int sortedIndex) { + List sorted = getSortedAccounts(); + if (sortedIndex < 0 || sortedIndex >= sorted.size()) { + return null; + } + return sorted.get(sortedIndex); + } + + public AccountEntry findByName(String name) { + String normalized = normalizeName(name); + if (normalized.isEmpty()) { + return null; + } + for (AccountEntry entry : accounts.get()) { + if (normalizeName(entry.getName()).equals(normalized)) { + return entry; + } + } + return null; + } + + public AccountEntry addAccount(String name) { + return addAccount(name, nowString(), null); + } + + public AccountEntry addAccount(String name, String date, Identifier skin) { + String cleanName = normalizeName(name); + if (cleanName.isEmpty()) { + return null; + } + + AccountEntry existing = findByName(cleanName); + if (existing != null) { + existing.setAuthType(AccountAuthType.OFFLINE); + existing.setOnlineUuid(null); + existing.setMicrosoftRefreshToken(""); + clearAuthorizationRequired(existing.getName()); + if (!isBlank(date)) { + existing.setDate(date); + } + if (skin != null) { + existing.setSkin(skin); + } + save(); + return existing; + } + + AccountEntry entry = new AccountEntry(cleanName, isBlank(date) ? nowString() : date, skin, false, accounts.get().size()); + accounts.get().add(entry); + save(); + return entry; + } + + public AccountEntry addMicrosoftAccount(MicrosoftSessionResult session) { + if (session == null || isBlank(session.name()) || session.uuid() == null || isBlank(session.refreshToken())) { + return null; + } + + AccountEntry existing = findByName(session.name()); + if (existing != null) { + existing.applyMicrosoftSession(session); + existing.setDate(nowString()); + clearAuthorizationRequired(existing.getName()); + save(); + return existing; + } + + AccountEntry entry = new AccountEntry( + session.name(), + nowString(), + null, + false, + accounts.get().size(), + AccountAuthType.MICROSOFT, + session.uuid(), + session.refreshToken() + ); + accounts.get().add(entry); + clearAuthorizationRequired(entry.getName()); + save(); + return entry; + } + + public void removeAccount(AccountEntry entry) { + if (entry == null) { + return; + } + if (!accounts.get().remove(entry)) { + return; + } + + if (isActive(entry)) { + clearActiveAccount(); + } else { + reindexAccounts(); + save(); + } + } + + public void removeAccountBySortedIndex(int sortedIndex) { + removeAccount(getAccountBySortedIndex(sortedIndex)); + } + + public void clearAllAccounts() { + accounts.get().clear(); + activeAccountName.set(""); + activeAccountDate.set(""); + activeAccountSkin.set(""); + microsoftAuthorizationRequiredName = ""; + save(); + } + + public void togglePin(int sortedIndex) { + AccountEntry entry = getAccountBySortedIndex(sortedIndex); + if (entry == null) { + return; + } + entry.togglePinned(); + save(); + } + + public void setActiveAccount(AccountEntry entry) { + if (entry == null) { + clearActiveAccount(); + return; + } + + activeAccountName.set(normalizeName(entry.getName())); + activeAccountDate.set(entry.getDate() == null ? "" : entry.getDate()); + Identifier skin = entry.getSkin(); + activeAccountSkin.set(skin == null ? "" : skin.toString()); + applySession(entry); + save(); + } + + public void setActiveAccount(String name, String date, Identifier skin) { + String cleanName = normalizeName(name); + if (cleanName.isEmpty()) { + clearActiveAccount(); + return; + } + + AccountEntry entry = findByName(cleanName); + if (entry == null) { + entry = addAccount(cleanName, date, skin); + } else { + if (!isBlank(date)) { + entry.setDate(date); + } + if (skin != null) { + entry.setSkin(skin); + } + } + setActiveAccount(entry); + } + + public void clearActiveAccount() { + activeAccountName.set(""); + activeAccountDate.set(""); + activeAccountSkin.set(""); + reindexAccounts(); + save(); + } + + public String getActiveAccountName() { + return activeAccountName.get(); + } + + public String getActiveAccountDate() { + return activeAccountDate.get(); + } + + public Identifier getActiveAccountSkin() { + return parseIdentifier(activeAccountSkin.get()); + } + + public boolean isMicrosoftAuthorizationRequired() { + return getMicrosoftAuthorizationRequiredAccount() != null; + } + + public AccountEntry getMicrosoftAuthorizationRequiredAccount() { + if (isBlank(microsoftAuthorizationRequiredName)) { + return null; + } + AccountEntry entry = findByName(microsoftAuthorizationRequiredName); + return entry != null && entry.isMicrosoft() ? entry : null; + } + + public void clearMicrosoftAuthorizationRequired() { + microsoftAuthorizationRequiredName = ""; + } + + public void save() { + reindexAccounts(); + ConfigSerializer.requestSave(this); + } + + @Override + public String getConfigName() { + return "accounts"; + } + + @Override + public List> getConfigValues() { + List> values = new ArrayList<>(); + values.add(accounts); + values.add(activeAccountName); + values.add(activeAccountDate); + values.add(activeAccountSkin); + return values; + } + + private void applyActiveSession() { + if (isBlank(activeAccountName.get())) { + return; + } + AccountEntry entry = findByName(activeAccountName.get()); + if (entry != null) { + applySession(entry); + } else { + SessionChanger.changeUsername(activeAccountName.get()); + } + } + + private void applySession(AccountEntry entry) { + if (entry == null) { + return; + } + + if (!entry.isMicrosoft()) { + clearAuthorizationRequired(entry.getName()); + SessionChanger.changeUsername(entry.getName()) + .exceptionally(t -> { + DebugLog.error("[AccountConfig] Failed to apply offline account session: " + entry.getName(), t); + return null; + }); + return; + } + + String refreshToken = entry.getMicrosoftRefreshToken(); + if (isBlank(refreshToken)) { + if (SessionChanger.canUseOriginalLauncherSession(entry.getName(), entry.getOnlineUuid())) { + clearAuthorizationRequired(entry.getName()); + SessionChanger.restoreOriginalLauncherSession(false) + .exceptionally(t -> { + DebugLog.error("[AccountConfig] Failed to restore launcher Microsoft session: " + entry.getName(), t); + markAuthorizationRequired(entry); + return null; + }); + return; + } + markAuthorizationRequired(entry); + DebugLog.warnOnce("alt-microsoft-token-empty-" + entry.getName(), "[AccountConfig] Microsoft account requires authorization: %s", entry.getName()); + return; + } + + clearAuthorizationRequired(entry.getName()); + MicrosoftAuthService.loginWithRefreshToken(refreshToken) + .thenCompose(session -> { + entry.applyMicrosoftSession(session); + activeAccountName.set(session.name()); + activeAccountDate.set(entry.getDate() == null ? "" : entry.getDate()); + save(); + return SessionChanger.changeSession(session.toLoginData()); + }) + .exceptionally(t -> { + DebugLog.error("[AccountConfig] Failed to apply Microsoft account session: " + entry.getName(), t); + markAuthorizationRequired(entry); + return null; + }); + } + + private void markAuthorizationRequired(AccountEntry entry) { + microsoftAuthorizationRequiredName = entry == null ? "" : normalizeName(entry.getName()); + } + + private void clearAuthorizationRequired(String name) { + if (normalizeName(name).equalsIgnoreCase(normalizeName(microsoftAuthorizationRequiredName))) { + microsoftAuthorizationRequiredName = ""; + } + } + + private boolean isActive(AccountEntry entry) { + if (entry == null) { + return false; + } + return normalizeName(entry.getName()).equals(normalizeName(activeAccountName.get())); + } + + private void reindexAccounts() { + List list = accounts.get(); + for (int i = 0; i < list.size(); i++) { + list.get(i).setOriginalIndex(i); + } + } + + private static final class AccountsValue extends ConfigValue> { + + private AccountsValue(String name) { + super(name, new ArrayList<>()); + } + + private static String stringValue(Object value) { + if (value == null) { + return ""; + } + return String.valueOf(value).trim(); + } + + private static boolean boolValue(Object value) { + if (value instanceof Boolean bool) { + return bool; + } + if (value instanceof String string) { + return Boolean.parseBoolean(string); + } + return false; + } + + private static int intValue(Object value, int fallback) { + if (value instanceof Number number) { + return Math.max(0, number.intValue()); + } + if (value instanceof String string) { + try { + return Math.max(0, Integer.parseInt(string.trim())); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + @Override + public Object toJson() { + List out = new ArrayList<>(); + for (AccountEntry entry : value) { + Map row = new LinkedHashMap<>(); + row.put("name", entry.getName()); + row.put("date", entry.getDate()); + row.put("skin", entry.getSkin() == null ? "" : entry.getSkin().toString()); + row.put("pinned", entry.isPinned()); + row.put("originalIndex", entry.getOriginalIndex()); + row.put("authType", entry.getAuthType().id()); + row.put("onlineUuid", entry.getOnlineUuid() == null ? "" : entry.getOnlineUuid().toString()); + row.put("microsoftRefreshToken", ""); + out.add(row); + } + return out; + } + + @Override + public void fromJson(Object json) { + List loaded = new ArrayList<>(); + if (json instanceof Iterable iterable) { + int fallbackIndex = 0; + for (Object item : iterable) { + if (!(item instanceof Map row)) { + continue; + } + + String name = stringValue(row.get("name")); + if (name.isBlank()) { + continue; + } + + String date = stringValue(row.get("date")); + Identifier skin = parseIdentifier(stringValue(row.get("skin"))); + boolean pinned = boolValue(row.get("pinned")); + int originalIndex = intValue(row.get("originalIndex"), fallbackIndex); + AccountAuthType authType = AccountAuthType.byId(stringValue(row.get("authType"))); + UUID onlineUuid = parseUuid(stringValue(row.get("onlineUuid"))); + loaded.add(new AccountEntry(name, date, skin, pinned, originalIndex, authType, onlineUuid, "")); + fallbackIndex++; + } + } + + loaded.sort(Comparator.comparingInt(AccountEntry::getOriginalIndex)); + value = loaded; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountEntry.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountEntry.java new file mode 100644 index 0000000..5787c29 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/AccountEntry.java @@ -0,0 +1,91 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.account; + +import dev.loki.lovisual.util.session.MicrosoftSessionResult; +import lombok.Getter; +import lombok.Setter; +import net.minecraft.resources.Identifier; + +import java.util.UUID; + +@Getter +public final class AccountEntry { + + private String name; + private String date; + @Setter + private Identifier skin; + @Setter + private boolean pinned; + private int originalIndex; + private AccountAuthType authType; + @Setter + private UUID onlineUuid; + private String microsoftRefreshToken; + + public AccountEntry(String name, String date, Identifier skin, boolean pinned, int originalIndex) { + this(name, date, skin, pinned, originalIndex, AccountAuthType.OFFLINE, null, ""); + } + + public AccountEntry(String name, + String date, + Identifier skin, + boolean pinned, + int originalIndex, + AccountAuthType authType, + UUID onlineUuid, + String microsoftRefreshToken) { + this.name = name == null ? "" : name; + this.date = date == null ? "" : date; + this.skin = skin; + this.pinned = pinned; + this.originalIndex = Math.max(0, originalIndex); + this.authType = authType == null ? AccountAuthType.OFFLINE : authType; + this.onlineUuid = onlineUuid; + this.microsoftRefreshToken = microsoftRefreshToken == null ? "" : microsoftRefreshToken; + } + + public void setName(String name) { + this.name = name == null ? "" : name; + } + + public void setDate(String date) { + this.date = date == null ? "" : date; + } + + public void togglePinned() { + pinned = !pinned; + } + + public void setOriginalIndex(int originalIndex) { + this.originalIndex = Math.max(0, originalIndex); + } + + public void setAuthType(AccountAuthType authType) { + this.authType = authType == null ? AccountAuthType.OFFLINE : authType; + } + + public boolean isMicrosoft() { + return authType == AccountAuthType.MICROSOFT; + } + + public void setMicrosoftRefreshToken(String microsoftRefreshToken) { + this.microsoftRefreshToken = microsoftRefreshToken == null ? "" : microsoftRefreshToken; + } + + public void applyMicrosoftSession(MicrosoftSessionResult session) { + if (session == null) { + return; + } + this.authType = AccountAuthType.MICROSOFT; + this.name = session.name(); + this.onlineUuid = session.uuid(); + this.microsoftRefreshToken = session.refreshToken(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/SkinManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/SkinManager.java new file mode 100644 index 0000000..f962797 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/account/SkinManager.java @@ -0,0 +1,183 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.account; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.authlib.GameProfile; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.DefaultPlayerSkin; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.PlayerSkin; +import dev.loki.lovisual.util.player.PlayerSkinResolver; + +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Supplier; + +public enum SkinManager { + ; + + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final Map LOADING = new ConcurrentHashMap<>(); + private static final Executor EXECUTOR = Executors.newFixedThreadPool(3, runnable -> { + Thread thread = new Thread(runnable, "LoVisual-AltSkinLoader"); + thread.setDaemon(true); + return thread; + }); + + public static Identifier getSkin(String playerName) { + String key = normalizeKey(playerName); + if (key.isEmpty()) { + return defaultSkin(playerName); + } + + Identifier cached = CACHE.get(key); + if (cached != null) { + return cached; + } + + if (LOADING.putIfAbsent(key, Boolean.TRUE) == null) { + CompletableFuture.runAsync(() -> loadSkin(playerName, key), EXECUTOR); + } + + return defaultSkin(playerName); + } + + public static void removeSkin(String playerName) { + String key = normalizeKey(playerName); + if (key.isEmpty()) { + return; + } + CACHE.remove(key); + LOADING.remove(key); + } + + public static void reloadSkin(String playerName) { + removeSkin(playerName); + getSkin(playerName); + } + + public static void clearCache() { + CACHE.clear(); + LOADING.clear(); + } + + private static void loadSkin(String playerName, String key) { + try { + UUID uuid = fetchUuid(playerName); + if (uuid == null) { + return; + } + + Minecraft client = Minecraft.getInstance(); + if (client == null || client.getSkinManager() == null) { + return; + } + + GameProfile profile = new GameProfile(uuid, playerName); + Supplier supplier = client.getSkinManager().createLookup(profile, false); + if (supplier == null) { + return; + } + + PlayerSkin textures = supplier.get(); + Identifier skin = PlayerSkinResolver.resolveAssetId(textures == null ? null : textures.body()); + if (skin != null) { + CACHE.put(key, skin); + } + } catch (Throwable ignored) { + } finally { + LOADING.remove(key); + } + } + + private static UUID fetchUuid(String playerName) { + String cleanName = normalizeName(playerName); + if (cleanName.isEmpty()) { + return null; + } + + try { + HttpURLConnection connection = (HttpURLConnection) URI + .create("https://api.mojang.com/users/profiles/minecraft/" + cleanName) + .toURL() + .openConnection(); + connection.setRequestMethod("GET"); + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty("User-Agent", "LoVisual"); + + if (connection.getResponseCode() != 200) { + connection.disconnect(); + return null; + } + + try (InputStreamReader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + if (!json.has("id")) { + return null; + } + return parseUuid(json.get("id").getAsString()); + } finally { + connection.disconnect(); + } + } catch (Throwable ignored) { + return null; + } + } + + private static UUID parseUuid(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + + String value = raw.trim(); + if (value.length() == 32) { + value = value.substring(0, 8) + "-" + value.substring(8, 12) + "-" + value.substring(12, 16) + + "-" + value.substring(16, 20) + "-" + value.substring(20); + } + + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static Identifier defaultSkin(String playerName) { + String cleanName = normalizeName(playerName); + UUID uuid = UUID.nameUUIDFromBytes(("OfflinePlayer:" + cleanName).getBytes(StandardCharsets.UTF_8)); + GameProfile profile = new GameProfile(uuid, cleanName); + PlayerSkin textures = DefaultPlayerSkin.get(profile); + Identifier resolved = PlayerSkinResolver.resolveAssetId(textures == null ? null : textures.body()); + if (resolved != null) { + return resolved; + } + return Identifier.fromNamespaceAndPath("minecraft", "textures/entity/player/wide/steve.png"); + } + + private static String normalizeKey(String playerName) { + String cleanName = normalizeName(playerName); + return cleanName.isEmpty() ? "" : cleanName.toLowerCase(); + } + + private static String normalizeName(String playerName) { + return playerName == null ? "" : playerName.trim(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/ClientCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/ClientCommand.java new file mode 100644 index 0000000..9e5b656 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/ClientCommand.java @@ -0,0 +1,63 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import java.util.List; + +public interface ClientCommand { + default CommandMetadata metadata() { + return CommandMetadata.from(this); + } + + /** + * Legacy addon API v0 metadata hook. New commands must use {@link CommandInfo}. + */ + @Deprecated(forRemoval = true) + default String name() { + return CommandMetadata.fromAnnotation(getClass()).id(); + } + + /** + * Legacy addon API v0 metadata hook. New commands must use {@link CommandInfo}. + */ + @Deprecated(forRemoval = true) + default List aliases() { + CommandMetadata metadata = CommandMetadata.annotated(getClass()); + return metadata == null ? List.of() : metadata.aliases(); + } + + /** + * Legacy addon API v0 metadata hook. New commands must use {@link CommandInfo}. + */ + @Deprecated(forRemoval = true) + @SuppressWarnings("removal") + default String usage() { + CommandMetadata metadata = CommandMetadata.annotated(getClass()); + return metadata == null ? name() : metadata.usage(); + } + + /** + * Legacy addon API v0 metadata hook. New commands must use {@link CommandInfo}. + */ + @Deprecated(forRemoval = true) + @SuppressWarnings("removal") + default String description() { + CommandMetadata metadata = CommandMetadata.annotated(getClass()); + return metadata == null ? usage() : metadata.description(); + } + + default boolean isAvailable() { + return true; + } + + default List suggest(CommandContext ctx, int argIndex, String token) { + return List.of(); + } + + boolean execute(CommandContext ctx); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandContext.java new file mode 100644 index 0000000..416fd2f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandContext.java @@ -0,0 +1,20 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import net.minecraft.client.Minecraft; + +import java.util.List; + +public record CommandContext(Minecraft mc, String raw, String name, List args) { + + public String arg(int idx) { + if (idx < 0 || idx >= args.size()) return null; + return args.get(idx); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandInfo.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandInfo.java new file mode 100644 index 0000000..5a50461 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandInfo.java @@ -0,0 +1,28 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import dev.loki.lovisual.events.UsedImplicitly; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@UsedImplicitly +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface CommandInfo { + String id(); + + String[] aliases() default {}; + + String usage() default ""; + + String descriptionKey() default ""; +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandManager.java new file mode 100644 index 0000000..32dab89 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandManager.java @@ -0,0 +1,212 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.suggestion.Suggestion; +import dev.loki.lovisual.util.logging.DebugLog; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ScanResult; +import net.minecraft.client.Minecraft; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public enum CommandManager { + ; + private static final List COMMANDS = new ArrayList<>(); + private static boolean initialized; + + public static synchronized void init() { + if (initialized) return; + initialized = true; + discover("dev.loki.lovisual.features.command.impl"); + } + + public static List getCommands() { + if (!initialized) init(); + return Collections.unmodifiableList(COMMANDS); + } + + public static void register(ClientCommand cmd) { + if (cmd == null) return; + CommandMetadata metadata = cmd.metadata(); + if (findRegistered(metadata.id()) != null) { + throw new IllegalArgumentException("Duplicate client command: @" + metadata.id()); + } + for (String alias : metadata.aliases()) { + if (findRegistered(alias) != null) { + throw new IllegalArgumentException("Duplicate client command alias: @" + alias); + } + } + COMMANDS.add(cmd); + } + + public static boolean handle(String raw) { + if (raw == null) return false; + String trimmed = raw.trim(); + if (!isCommandLike(trimmed)) return false; + if (!initialized) init(); + + String body = trimmed.substring(1).trim(); + if (body.isEmpty()) return false; + String[] parts = body.split("\\s+"); + if (parts.length == 0) return false; + String name = parts[0].toLowerCase(); + + ClientCommand cmd = find(name); + if (cmd == null) { + CommandOutput.error("Unknown client command: @" + name + ". Use @help."); + return true; + } + if (!cmd.isAvailable()) { + return true; // silently ignore when command is disabled + } + + List args = new ArrayList<>(); + args.addAll(Arrays.asList(parts).subList(1, parts.length)); + CommandContext ctx = new CommandContext(Minecraft.getInstance(), raw, name, args); + return cmd.execute(ctx); + } + + public static List suggest(String input, int cursor) { + if (input == null) return List.of(); + int start = firstNonWhitespace(input); + if (start < 0 || input.charAt(start) != '@') return List.of(); + if (!initialized) init(); + + int cursorClamped = Math.max(start + 1, Math.min(cursor, input.length())); + String before = input.substring(start + 1, cursorClamped); + String[] parts = before.split("\\s+", -1); + if (parts.length == 0) return List.of(); + + int argIndex = parts.length - 1; + String token = parts[argIndex]; + String cmdName = parts[0].toLowerCase(); + + int tokenStartInBefore = before.lastIndexOf(' '); + tokenStartInBefore = tokenStartInBefore >= 0 ? tokenStartInBefore + 1 : 0; + int tokenStartInInput = start + 1 + tokenStartInBefore; + int tokenEndInInput = tokenStartInInput + token.length(); + StringRange range = StringRange.between(tokenStartInInput, tokenEndInInput); + + List suggestions; + if (argIndex == 0) { + suggestions = suggestCommandNames(token); + } else { + ClientCommand cmd = find(cmdName); + if (cmd == null || !cmd.isAvailable()) return List.of(); + List args = new ArrayList<>(); + args.addAll(Arrays.asList(parts).subList(1, parts.length)); + CommandContext ctx = new CommandContext(Minecraft.getInstance(), input, cmdName, args); + suggestions = cmd.suggest(ctx, argIndex, token); + } + + if (suggestions == null || suggestions.isEmpty()) return List.of(); + List out = new ArrayList<>(suggestions.size()); + for (String s : suggestions) { + if (s == null || s.isBlank()) continue; + out.add(new Suggestion(range, s)); + } + return out; + } + + public static ClientCommand find(String name) { + if (!initialized) init(); + return findRegistered(name); + } + + private static ClientCommand findRegistered(String name) { + if (name == null || name.isBlank()) return null; + for (ClientCommand cmd : COMMANDS) { + CommandMetadata metadata = cmd.metadata(); + if (metadata.id().equalsIgnoreCase(name)) return cmd; + for (String alias : metadata.aliases()) { + if (alias.equalsIgnoreCase(name)) return cmd; + } + } + return null; + } + + private static boolean isCommandLike(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isWhitespace(c)) continue; + return c == '@'; + } + return false; + } + + private static List suggestCommandNames(String token) { + String lower = token == null ? "" : token.toLowerCase(); + List out = new ArrayList<>(); + for (ClientCommand cmd : COMMANDS) { + if (!cmd.isAvailable()) continue; + CommandMetadata metadata = cmd.metadata(); + if (lower.isEmpty() || metadata.id().startsWith(lower)) { + out.add(metadata.id()); + } + for (String alias : metadata.aliases()) { + if (alias == null || alias.isBlank()) continue; + if (lower.isEmpty() || alias.startsWith(lower)) { + out.add(alias); + } + } + } + return out; + } + + private static int firstNonWhitespace(String text) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (!Character.isWhitespace(c)) return i; + } + return -1; + } + + private static void discover(String basePackage) { + try (ScanResult scan = new ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .acceptPackages(basePackage) + .scan()) { + scan.getClassesWithAnnotation(CommandInfo.class.getName()).stream() + .map(ClassInfo::getName) + .sorted(String.CASE_INSENSITIVE_ORDER) + .forEach(CommandManager::loadCandidate); + } + } + + private static void loadCandidate(String className) { + try { + Class type = Class.forName(className, false, CommandManager.class.getClassLoader()); + if (!ClientCommand.class.isAssignableFrom(type)) { + DebugLog.config("Skipping annotated non-command: %s", type.getName()); + return; + } + int modifiers = type.getModifiers(); + if (Modifier.isAbstract(modifiers) || Modifier.isInterface(modifiers) || type.isAnnotation()) { + DebugLog.config("Skipping non-concrete command class: %s", type.getName()); + return; + } + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + register((ClientCommand) constructor.newInstance()); + DebugLog.config("Loaded command: %s", type.getName()); + } catch (NoSuchMethodException exception) { + DebugLog.error("Failed to load command %s: no no-args constructor", exception, className); + } catch (Throwable throwable) { + DebugLog.error("Failed to load command: %s", throwable, className); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandMetadata.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandMetadata.java new file mode 100644 index 0000000..30991b0 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandMetadata.java @@ -0,0 +1,124 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import net.minecraft.client.resources.language.I18n; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public final class CommandMetadata { + private static final ClassValue> ANNOTATED = new ClassValue<>() { + @Override + protected Optional computeValue(Class type) { + CommandInfo info = type.getAnnotation(CommandInfo.class); + return info == null ? Optional.empty() : Optional.of(from(info)); + } + }; + + private final String id; + private final List aliases; + private final String usage; + private final String description; + private final boolean localizedDescription; + + private CommandMetadata(String id, + List aliases, + String usage, + String description, + boolean localizedDescription) { + this.id = normalizeId(id); + this.aliases = normalizeAliases(aliases, this.id); + this.usage = usage == null || usage.isBlank() ? "@" + this.id : usage.trim(); + if (description == null || description.isBlank()) { + this.description = this.usage; + this.localizedDescription = false; + } else { + this.description = description.trim(); + this.localizedDescription = localizedDescription; + } + } + + public String id() { + return id; + } + + public List aliases() { + return aliases; + } + + public String usage() { + return usage; + } + + public String description() { + return localizedDescription ? I18n.get(description) : description; + } + + public String descriptionKey() { + return localizedDescription ? description : ""; + } + + public static CommandMetadata from(ClientCommand command) { + if (command == null) throw new IllegalArgumentException("command cannot be null"); + + CommandMetadata annotated = annotated(command.getClass()); + if (annotated != null) return annotated; + return fromLegacy(command); + } + + static CommandMetadata fromAnnotation(Class type) { + CommandMetadata annotated = annotated(type); + if (annotated == null) { + throw new IllegalStateException(type.getName() + " must be annotated with @CommandInfo"); + } + return annotated; + } + + static CommandMetadata annotated(Class type) { + return ANNOTATED.get(type).orElse(null); + } + + private static CommandMetadata from(CommandInfo info) { + return new CommandMetadata(info.id(), List.of(info.aliases()), info.usage(), info.descriptionKey(), true); + } + + @SuppressWarnings("removal") + private static CommandMetadata fromLegacy(ClientCommand command) { + String id = command.name(); + List aliases = command.aliases(); + String usage = command.usage(); + String description = command.description(); + return new CommandMetadata(id, aliases, usage, description, false); + } + + private static String normalizeId(String id) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("Command id cannot be blank"); + String normalized = id.trim().toLowerCase(Locale.ROOT); + if (normalized.charAt(0) == '@') normalized = normalized.substring(1); + if (normalized.isBlank() || normalized.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Invalid command id: " + id); + } + return normalized; + } + + private static List normalizeAliases(List aliases, String id) { + if (aliases == null || aliases.isEmpty()) return List.of(); + List normalized = new ArrayList<>(aliases.size()); + for (String alias : aliases) { + if (alias == null || alias.isBlank()) continue; + String value = alias.trim().toLowerCase(Locale.ROOT); + if (value.charAt(0) == '@') value = value.substring(1); + if (value.isBlank() || value.chars().anyMatch(Character::isWhitespace) || value.equals(id)) continue; + if (!normalized.contains(value)) normalized.add(value); + } + return List.copyOf(normalized); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandOutput.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandOutput.java new file mode 100644 index 0000000..e6a586f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/CommandOutput.java @@ -0,0 +1,195 @@ +/* + * This file is part of the LoVisual Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; + +public enum CommandOutput { + ; + + public static final String PREFIX = "[LoVisual]"; + private static final int MAX_RECENT_MESSAGES = 128; + private static final Map LOVISUAL_MESSAGES = + Collections.synchronizedMap(new WeakHashMap<>()); + private static final Deque RECENT_MESSAGES = new ArrayDeque<>(); + + public record MessageLine(Component text, long timestampMs, Tone tone) { + } + + public enum Tone { + INFO, + SUCCESS, + WARNING, + ERROR + } + + public static void send(String message) { + send(message, Tone.INFO); + } + + public static void success(String message) { + send(message, Tone.SUCCESS); + } + + public static void warning(String message) { + send(message, Tone.WARNING); + } + + public static void error(String message) { + send(message, Tone.ERROR); + } + + public static void send(String message, Tone tone) { + if (message == null || message.isBlank()) return; + send(Component.literal(message), tone == null ? Tone.INFO : tone); + } + + public static void send(Component text) { + send(text, Tone.INFO); + } + + public static void send(Component text, Tone tone) { + if (text == null) return; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null || mc.gui.hud == null) return; + + Tone resolvedTone = tone == null ? Tone.INFO : tone; + Component formatted = mark(format(text, resolvedTone), resolvedTone); + remember(formatted, resolvedTone); + mc.gui.hud.getChat().addClientSystemMessage(formatted); + } + + public static boolean isLoVisualMessage(Component text) { + if (text == null) return false; + synchronized (LOVISUAL_MESSAGES) { + return LOVISUAL_MESSAGES.containsKey(text); + } + } + + public static Tone toneOf(Component text) { + if (text == null) return Tone.INFO; + synchronized (LOVISUAL_MESSAGES) { + Tone tone = LOVISUAL_MESSAGES.get(text); + return tone == null ? Tone.INFO : tone; + } + } + + public static List recentMessages() { + synchronized (RECENT_MESSAGES) { + return new ArrayList<>(RECENT_MESSAGES); + } + } + + public static void clearRecentMessages() { + synchronized (RECENT_MESSAGES) { + RECENT_MESSAGES.clear(); + } + } + + private static void remember(Component text, Tone tone) { + synchronized (RECENT_MESSAGES) { + RECENT_MESSAGES.addLast(new MessageLine(text, System.currentTimeMillis(), tone)); + while (RECENT_MESSAGES.size() > MAX_RECENT_MESSAGES) { + RECENT_MESSAGES.removeFirst(); + } + } + } + + private static Component mark(Component text, Tone tone) { + synchronized (LOVISUAL_MESSAGES) { + LOVISUAL_MESSAGES.put(text, tone == null ? Tone.INFO : tone); + } + return text; + } + + private static MutableComponent format(Component body, Tone tone) { + MutableComponent out = Component.empty(); + appendGradientPrefix(out, tone); + out.append(Component.literal(" ")); + out.append(copyBody(body, bodyColor(tone)).withStyle(style -> style.withBold(false))); + return out; + } + + private static MutableComponent copyBody(Component body, int color) { + MutableComponent copy = body.copy(); + return copy.withStyle(style -> style.withColor(color & 0x00FFFFFF)); + } + + private static void appendGradientPrefix(MutableComponent out, Tone tone) { + String prefix = PREFIX; + int start = prefixStartColor(tone); + int end = prefixEndColor(tone); + int steps = Math.max(1, prefix.length() - 1); + for (int i = 0; i < prefix.length(); i++) { + final int color = mixRgb(start, end, i / (float) steps) & 0x00FFFFFF; + out.append(Component.literal(String.valueOf(prefix.charAt(i))) + .withStyle(style -> style.withColor(color).withBold(true))); + } + } + + public static int prefixStartColor(Tone tone) { + Themes.Theme theme = Theme.theme(); + int accent = theme.accent(); + return switch (tone) { + case SUCCESS -> mixRgb(accent, 0xFF55FF93, 0.45f); + case WARNING -> mixRgb(accent, 0xFFFFC85A, 0.45f); + case ERROR -> mixRgb(accent, 0xFFFF6A6A, 0.55f); + default -> accent; + }; + } + + public static int prefixEndColor(Tone tone) { + Themes.Theme theme = Theme.theme(); + int text = theme.textPrimary(); + int accentSoft = theme.accentSoft(); + return switch (tone) { + case SUCCESS -> mixRgb(text, 0xFF55FF93, 0.28f); + case WARNING -> mixRgb(text, 0xFFFFD985, 0.32f); + case ERROR -> mixRgb(text, 0xFFFF8A8A, 0.42f); + default -> mixRgb(text, accentSoft, 0.35f); + }; + } + + public static int bodyColor(Tone tone) { + Themes.Theme theme = Theme.theme(); + int base = mixRgb(theme.textPrimary(), 0xFFFFFFFF, 0.24f); + return switch (tone) { + case SUCCESS -> mixRgb(base, 0xFFBFFFE0, 0.18f); + case WARNING -> mixRgb(base, 0xFFFFE5A8, 0.18f); + case ERROR -> mixRgb(base, 0xFFFFB0B0, 0.25f); + default -> base; + }; + } + + public static int mixRgb(int a, int b, float t) { + t = Math.max(0.0f, Math.min(1.0f, t)); + int ar = (a >> 16) & 0xFF; + int ag = (a >> 8) & 0xFF; + int ab = a & 0xFF; + int br = (b >> 16) & 0xFF; + int bg = (b >> 8) & 0xFF; + int bb = b & 0xFF; + int r = Math.round(ar + (br - ar) * t); + int g = Math.round(ag + (bg - ag) * t); + int bl = Math.round(ab + (bb - ab) * t); + return 0xFF000000 | (r << 16) | (g << 8) | bl; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/VanillaChatRuntimeCleanup.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/VanillaChatRuntimeCleanup.java new file mode 100644 index 0000000..df1b4a8 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/VanillaChatRuntimeCleanup.java @@ -0,0 +1,25 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.ChatComponent; + +public enum VanillaChatRuntimeCleanup { + ; + + public static void clearVanillaChatCache() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.gui == null || mc.gui.hud == null) return; + ChatComponent chat = mc.gui.hud.getChat(); + if (chat != null) { + chat.clearMessages(true); + } + CommandOutput.clearRecentMessages(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/AddonsCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/AddonsCommand.java new file mode 100644 index 0000000..8c6f089 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/AddonsCommand.java @@ -0,0 +1,98 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.addon.AddonIssue; +import dev.loki.lovisual.addon.AddonManager; +import dev.loki.lovisual.addon.AddonSnapshot; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "addons", + usage = "@addons [list|scan|enable|disable] [id]", + descriptionKey = "command.addons.description" +) +public final class AddonsCommand implements ClientCommand { + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + if (argIndex == 1) { + return List.of("list", "scan", "enable", "disable").stream() + .filter(value -> value.startsWith(lower)) + .toList(); + } + if (argIndex == 2) { + return AddonManager.snapshots().stream() + .map(AddonSnapshot::id) + .filter(id -> lower.isEmpty() || id.startsWith(lower)) + .toList(); + } + return List.of(); + } + + @Override + public boolean execute(CommandContext ctx) { + String action = ctx.arg(0); + if (action == null || action.isBlank() || "list".equalsIgnoreCase(action)) { + list(); + return true; + } + if ("scan".equalsIgnoreCase(action)) { + scan(); + return true; + } + if ("enable".equalsIgnoreCase(action) || "disable".equalsIgnoreCase(action)) { + String id = ctx.arg(1); + if (id == null || id.isBlank()) { + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + boolean enabled = "enable".equalsIgnoreCase(action); + boolean ok = AddonManager.setEnabled(id, enabled); + CommandOutput.send(ok + ? "Addon " + id + " " + (enabled ? "enabled" : "disabled") + : "Addon not found: " + id); + return true; + } + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + + private static void list() { + List addons = AddonManager.snapshots(); + if (addons.isEmpty()) { + CommandOutput.send("No LoVisual addons installed."); + return; + } + for (AddonSnapshot addon : addons) { + CommandOutput.send(addon.id() + " " + addon.version() + + " [" + addon.status().name().toLowerCase(Locale.ROOT) + "]" + + " modules=" + addon.modules() + + " hud=" + (addon.draggableHudElements() + addon.staticHudElements()) + + " commands=" + addon.commands() + + " clickGui=" + addon.clickGuiSections() + + " extensions=" + addon.moduleExtensions() + + " irisPatches=" + addon.irisPatchManifests() + + (addon.restartRequired() ? " restart-required" : "")); + } + } + + private static void scan() { + for (AddonIssue issue : AddonManager.scan()) { + CommandOutput.send("Addon scan " + issue.severity().name().toLowerCase(Locale.ROOT) + + " [" + issue.addonId() + "]: " + issue.message() + + (issue.detail() == null || issue.detail().isBlank() ? "" : " (" + issue.detail() + ")")); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/BindCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/BindCommand.java new file mode 100644 index 0000000..5727fb6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/BindCommand.java @@ -0,0 +1,83 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.util.input.KeyUtil; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "bind", + aliases = {"binds", "keybind"}, + usage = "@bind | @bind list", + descriptionKey = "command.bind.description" +) +public final class BindCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + String moduleArg = ctx.arg(0); + if (moduleArg != null && moduleArg.equalsIgnoreCase("list")) { + listBinds(); + return true; + } + Module module = CommandUtils.findModule(moduleArg); + if (module == null) { + CommandOutput.error(moduleArg == null ? "Usage: " + metadata().usage() : "Module not found: " + moduleArg); + return true; + } + String key = ctx.arg(1); + if (key == null) { + CommandOutput.send(module.getDisplayName() + " bind: " + module.getKeyBindSetting().getValue().get()); + return true; + } + String normalized = key.trim().toUpperCase(Locale.ROOT); + if (!normalized.equals("NONE") && KeyUtil.stringToCombo(normalized).isEmpty()) { + CommandOutput.error("Unknown key or combo: " + key); + return true; + } + module.getKeyBindSetting().setKeyByName(normalized); + module.saveConfig(); + CommandOutput.success(module.getDisplayName() + " bound to " + normalized); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex == 1) { + List modules = CommandUtils.suggestModules(token); + if (token == null || token.isBlank() || "list".startsWith(token.toLowerCase(Locale.ROOT))) { + java.util.ArrayList out = new java.util.ArrayList<>(modules); + out.add("list"); + return out; + } + return modules; + } + if (argIndex == 2) return List.of("NONE", "R", "V", "LEFT_CTRL+R"); + return List.of(); + } + + private static void listBinds() { + List bound = CommandUtils.sortedModules().stream() + .filter(module -> !module.getKeyBindSetting().getValue().isNone()) + .toList(); + if (bound.isEmpty()) { + CommandOutput.send("No module binds configured."); + return; + } + CommandOutput.send("Module binds: " + bound.size()); + for (Module module : bound) { + CommandOutput.send(module.getDisplayName() + " — " + module.getKeyBindSetting().getValue().get()); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CommandUtils.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CommandUtils.java new file mode 100644 index 0000000..9ea8b7c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CommandUtils.java @@ -0,0 +1,95 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.PlayerInfo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +final class CommandUtils { + private CommandUtils() { + } + + static Module findModule(String query) { + if (query == null || query.isBlank()) return null; + for (Module module : ModuleManager.getModules()) { + if (matchesModule(module, query)) return module; + } + return null; + } + + static List suggestModules(String token) { + String lower = normalize(token); + return ModuleManager.getModules().stream() + .map(Module::name) + .filter(name -> lower.isEmpty() || name.startsWith(lower)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + static List suggestPlayers(Minecraft mc, String token) { + if (mc == null || mc.getConnection() == null) return List.of(); + String lower = normalize(token); + List names = new ArrayList<>(); + for (PlayerInfo info : mc.getConnection().getOnlinePlayers()) { + if (info == null || info.getProfile() == null) continue; + String name = info.getProfile().name(); + if (name != null && (lower.isEmpty() || name.toLowerCase(Locale.ROOT).startsWith(lower))) { + names.add(name); + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } + + static List sortedModules() { + return ModuleManager.getModules().stream() + .sorted(Comparator.comparing(Module::name, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } + + static String join(List parts, int start) { + if (parts == null || start < 0 || start >= parts.size()) return ""; + return String.join(" ", parts.subList(start, parts.size())).trim(); + } + + static boolean parseState(String value, boolean current) { + if (value == null || value.isBlank() || value.equalsIgnoreCase("toggle")) return !current; + return value.equalsIgnoreCase("on") + || value.equalsIgnoreCase("true") + || value.equalsIgnoreCase("enable") + || value.equalsIgnoreCase("enabled") + || value.equals("1"); + } + + static boolean isState(String value) { + if (value == null) return true; + return switch (value.toLowerCase(Locale.ROOT)) { + case "toggle", "on", "off", "true", "false", "enable", "disable", "enabled", "disabled", "1", "0" -> true; + default -> false; + }; + } + + private static boolean matchesModule(Module module, String query) { + if (module.name().equalsIgnoreCase(query) || module.getDisplayName().equalsIgnoreCase(query)) return true; + for (String alias : module.getAliases()) { + if (alias.equalsIgnoreCase(query)) return true; + } + return false; + } + + private static String normalize(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ConfigCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ConfigCommand.java new file mode 100644 index 0000000..fc9d8af --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ConfigCommand.java @@ -0,0 +1,54 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.config.ConfigPaths; +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.module.ModuleManager; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "config", + aliases = {"cfg", "settings"}, + usage = "@config [save|load|path]", + descriptionKey = "command.config.description" +) +public final class ConfigCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + String action = ctx.arg(0) == null ? "save" : ctx.arg(0).toLowerCase(Locale.ROOT); + switch (action) { + case "save" -> { + ModuleManager.saveAllModuleConfigs(); + ConfigSerializer.saveAll(); + CommandOutput.success("Client config saved."); + } + case "load", "reload" -> { + ConfigSerializer.loadAll(); + ModuleManager.loadAllModuleConfigs(); + CommandOutput.success("Client config reloaded."); + } + case "path", "folder" -> CommandOutput.send("Config directory: " + ConfigPaths.root().toAbsolutePath()); + default -> CommandOutput.warning("Usage: " + metadata().usage()); + } + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("save", "load", "path").stream().filter(value -> value.startsWith(lower)).toList(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CoordinatesCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CoordinatesCommand.java new file mode 100644 index 0000000..b9978bf --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/CoordinatesCommand.java @@ -0,0 +1,35 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; + +import java.util.Locale; + +@CommandInfo( + id = "coordinates", + aliases = {"coords", "position", "pos"}, + usage = "@coordinates", + descriptionKey = "command.coordinates.description" +) +public final class CoordinatesCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + if (ctx.mc() == null || ctx.mc().player == null) { + CommandOutput.error("Player is unavailable."); + return true; + } + CommandOutput.send(String.format(Locale.ROOT, "Position: %.2f, %.2f, %.2f | Block: %d, %d, %d", + ctx.mc().player.getX(), ctx.mc().player.getY(), ctx.mc().player.getZ(), + ctx.mc().player.getBlockX(), ctx.mc().player.getBlockY(), ctx.mc().player.getBlockZ())); + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HelpCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HelpCommand.java new file mode 100644 index 0000000..f0842b0 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HelpCommand.java @@ -0,0 +1,157 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandManager; +import dev.loki.lovisual.features.command.CommandMetadata; +import dev.loki.lovisual.features.command.CommandOutput; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.MutableComponent; + +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "help", + aliases = {"commands", "cmds"}, + usage = "@help [page|command]", + descriptionKey = "command.help.description" +) +public final class HelpCommand implements ClientCommand { + private static final int PAGE_SIZE = 7; + private static final int TITLE_COLOR = 0xFF7EC8FF; + private static final int COMMAND_COLOR = 0xFFFFFFFF; + private static final int MUTED_COLOR = 0xFF9AA7B5; + private static final int ACTIVE_COLOR = 0xFF70E0A0; + + @Override + public boolean execute(CommandContext ctx) { + String selector = ctx.arg(0); + if (selector != null && !selector.isBlank() && !isInteger(selector)) { + ClientCommand command = CommandManager.find(selector.toLowerCase(Locale.ROOT)); + if (command == null || !command.isAvailable()) { + CommandOutput.error("Command not found: @" + selector); + return true; + } + showDetails(command); + return true; + } + + List commands = CommandManager.getCommands().stream() + .filter(ClientCommand::isAvailable) + .sorted(Comparator.comparing(command -> command.metadata().id(), String.CASE_INSENSITIVE_ORDER)) + .toList(); + int pages = Math.max(1, (commands.size() + PAGE_SIZE - 1) / PAGE_SIZE); + int page = Math.max(1, Math.min(parseInt(selector, 1), pages)); + int from = Math.min(commands.size(), (page - 1) * PAGE_SIZE); + int to = Math.min(commands.size(), from + PAGE_SIZE); + + MutableComponent message = Component.empty(); + message.append(Component.literal("Client commands " + commands.size()) + .withStyle(style -> style.withBold(true).withColor(TITLE_COLOR))); + message.append(Component.literal(" - page " + page + "/" + pages) + .withStyle(style -> style.withColor(MUTED_COLOR))); + + for (int i = from; i < to; i++) { + ClientCommand command = commands.get(i); + message.append(Component.literal("\n- ").withStyle(style -> style.withColor(MUTED_COLOR))); + message.append(commandLink(command)); + message.append(Component.literal(" - " + command.metadata().description()) + .withStyle(style -> style.withColor(MUTED_COLOR))); + } + + message.append(Component.literal("\n")); + if (page > 1) { + message.append(pageButton("< previous", page - 1)); + } else { + message.append(Component.literal("< previous").withStyle(style -> style.withColor(0xFF59616B))); + } + message.append(Component.literal(" ")); + if (page < pages) { + message.append(pageButton("next >", page + 1)); + } else { + message.append(Component.literal("next >").withStyle(style -> style.withColor(0xFF59616B))); + } + CommandOutput.send(message); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return CommandManager.getCommands().stream() + .filter(ClientCommand::isAvailable) + .map(command -> command.metadata().id()) + .filter(name -> lower.isEmpty() || name.startsWith(lower)) + .sorted() + .toList(); + } + + private static MutableComponent commandLink(ClientCommand command) { + CommandMetadata metadata = command.metadata(); + String insert = "@" + metadata.id() + " "; + String aliases = metadata.aliases().isEmpty() ? "none" : String.join(", ", metadata.aliases()); + Component hover = Component.literal(metadata.description() + + "\nUsage: " + metadata.usage() + + "\nAliases: " + aliases + + "\nClick to insert"); + return Component.literal("@" + metadata.id()) + .withStyle(style -> style + .withColor(COMMAND_COLOR) + .withUnderlined(true) + .withClickEvent(new ClickEvent.SuggestCommand(insert)) + .withHoverEvent(new HoverEvent.ShowText(hover))); + } + + private static MutableComponent pageButton(String label, int page) { + return Component.literal(label) + .withStyle(style -> style + .withBold(true) + .withColor(ACTIVE_COLOR) + .withClickEvent(new ClickEvent.RunCommand("@help " + page)) + .withHoverEvent(new HoverEvent.ShowText(Component.literal("Open help page " + page)))); + } + + private static void showDetails(ClientCommand command) { + CommandMetadata metadata = command.metadata(); + MutableComponent message = Component.literal("@" + metadata.id()) + .withStyle(style -> style.withBold(true).withColor(TITLE_COLOR)); + message.append(Component.literal("\n" + metadata.description()).withStyle(style -> style.withColor(COMMAND_COLOR))); + message.append(Component.literal("\nUsage: " + metadata.usage()).withStyle(style -> style.withColor(MUTED_COLOR))); + if (!metadata.aliases().isEmpty()) { + message.append(Component.literal("\nAliases: " + String.join(", ", metadata.aliases())) + .withStyle(style -> style.withColor(MUTED_COLOR))); + } + CommandOutput.send(message); + } + + private static boolean isInteger(String value) { + try { + Integer.parseInt(value); + return true; + } catch (NumberFormatException ignored) { + return false; + } + } + + private static int parseInt(String value, int fallback) { + if (value == null) return fallback; + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) { + return fallback; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HideCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HideCommand.java new file mode 100644 index 0000000..93cd50c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/HideCommand.java @@ -0,0 +1,53 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.module.Module; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "hide", + aliases = {"modulevisibility", "showmodule"}, + usage = "@hide [on|off|toggle]", + descriptionKey = "command.hide.description" +) +public final class HideCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + Module module = CommandUtils.findModule(ctx.arg(0)); + if (module == null) { + CommandOutput.error(ctx.arg(0) == null ? "Usage: " + metadata().usage() : "Module not found: " + ctx.arg(0)); + return true; + } + String action = ctx.arg(1); + if (!CommandUtils.isState(action)) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + boolean hidden = CommandUtils.parseState(action, !module.isShownInModuleList()); + module.setShownInModuleList(!hidden); + CommandOutput.success(module.getDisplayName() + (hidden ? " hidden from ModuleList" : " shown in ModuleList")); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex == 1) return CommandUtils.suggestModules(token); + if (argIndex == 2) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("on", "off", "toggle").stream().filter(value -> value.startsWith(lower)).toList(); + } + return List.of(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/IrisCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/IrisCommand.java new file mode 100644 index 0000000..de65b4e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/IrisCommand.java @@ -0,0 +1,67 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.render.iris.IrisCompatibilityFeature; +import dev.loki.lovisual.render.iris.IrisCompatibilityProfile; +import dev.loki.lovisual.render.iris.IrisRuntime; +import dev.loki.lovisual.render.iris.IrisRuntimeSnapshot; +import dev.loki.lovisual.render.iris.patch.ShaderPatchEngine; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.List; +import java.util.stream.Collectors; + +@CommandInfo( + id = "iris", + aliases = "shaderpack", + usage = "@iris", + descriptionKey = "command.iris.description" +) +public final class IrisCommand implements ClientCommand { + private static String formatFeature(IrisCompatibilityFeature feature) { + return feature.name().toLowerCase(java.util.Locale.ROOT); + } + + @Override + public boolean isAvailable() { + return !RuntimeGate.isPanic(); + } + + @Override + public boolean execute(CommandContext ctx) { + IrisRuntimeSnapshot snapshot = IrisRuntime.snapshot(); + CommandOutput.send(snapshot.shortLine()); + + if (!snapshot.modLoaded()) { + return true; + } + CommandOutput.send("Iris API: " + (snapshot.apiAvailable() ? "available" : "unavailable") + + ", status: " + snapshot.status() + + ", shadow pass: " + snapshot.renderingShadowPass()); + + IrisCompatibilityProfile profile = snapshot.profile(); + String features = profile.features().isEmpty() + ? "none" + : profile.features().stream() + .map(IrisCommand::formatFeature) + .collect(Collectors.joining(", ")); + CommandOutput.send("Iris profile: " + profile.getId() + + " (" + profile.displayName() + "), features: " + features); + List patchDiagnostics = ShaderPatchEngine.diagnostics(); + CommandOutput.send("Iris patch compiler: " + (patchDiagnostics.isEmpty() ? "no session data" : "session data follows")); + for (String diagnostic : patchDiagnostics) { + CommandOutput.send("patch " + diagnostic); + } + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ModulesCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ModulesCommand.java new file mode 100644 index 0000000..2712ab2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ModulesCommand.java @@ -0,0 +1,110 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleCategory; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.MutableComponent; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "modules", + aliases = {"modulelist", "mods"}, + usage = "@modules [all|enabled|combat|movement|player|visuals|misc] [page]", + descriptionKey = "command.modules.description" +) +public final class ModulesCommand implements ClientCommand { + private static final int PAGE_SIZE = 10; + + @Override + public boolean execute(CommandContext ctx) { + String filter = ctx.arg(0) == null ? "all" : ctx.arg(0).toLowerCase(Locale.ROOT); + List modules = CommandUtils.sortedModules().stream() + .filter(module -> accepts(module, filter)) + .toList(); + if (!filter.equals("all") && !filter.equals("enabled") && parseCategory(filter) == null) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + int pages = Math.max(1, (modules.size() + PAGE_SIZE - 1) / PAGE_SIZE); + int page = Math.max(1, Math.min(parsePage(ctx.arg(1)), pages)); + int from = Math.min(modules.size(), (page - 1) * PAGE_SIZE); + int to = Math.min(modules.size(), from + PAGE_SIZE); + + MutableComponent message = Component.literal("Modules " + filter + " (" + modules.size() + ") — " + page + "/" + pages) + .withStyle(style -> style.withBold(true).withColor(0xFF7EC8FF)); + for (int i = from; i < to; i++) { + Module module = modules.get(i); + int color = module.isEnabled() ? 0xFF70E0A0 : 0xFF9AA7B5; + message.append(Component.literal("\n• ")); + message.append(Component.literal(module.getDisplayName()) + .withStyle(style -> style + .withColor(color) + .withClickEvent(new ClickEvent.RunCommand("@toggle " + module.name())) + .withHoverEvent(new HoverEvent.ShowText(Component.literal( + module.getDescription() + "\nClick to toggle\nID: " + module.name()))))); + message.append(Component.literal(" [" + (module.isEnabled() ? "ON" : "OFF") + "]") + .withStyle(style -> style.withColor(color))); + } + if (pages > 1) { + message.append(Component.literal("\n")); + if (page > 1) message.append(pageButton("‹", filter, page - 1)); + message.append(Component.literal(" page " + page + "/" + pages + " ")); + if (page < pages) message.append(pageButton("›", filter, page + 1)); + } + CommandOutput.send(message); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("all", "enabled", "combat", "movement", "player", "visuals", "misc").stream() + .filter(value -> value.startsWith(lower)).toList(); + } + + private static boolean accepts(Module module, String filter) { + if (filter.equals("all")) return true; + if (filter.equals("enabled")) return module.isEnabled(); + ModuleCategory category = parseCategory(filter); + return category != null && module.getCategory() == category; + } + + private static ModuleCategory parseCategory(String value) { + try { + return ModuleCategory.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static int parsePage(String value) { + try { + return value == null ? 1 : Integer.parseInt(value); + } catch (NumberFormatException ignored) { + return 1; + } + } + + private static MutableComponent pageButton(String label, String filter, int page) { + return Component.literal(label).withStyle(style -> style + .withBold(true) + .withColor(0xFF70E0A0) + .withClickEvent(new ClickEvent.RunCommand("@modules " + filter + " " + page))); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/PingCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/PingCommand.java new file mode 100644 index 0000000..5266c46 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/PingCommand.java @@ -0,0 +1,30 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.util.player.NetworkStatsUtil; + +@CommandInfo( + id = "ping", + aliases = "latency", + usage = "@ping", + descriptionKey = "command.ping.description" +) +public final class PingCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + int ping = NetworkStatsUtil.getPing(ctx.mc()); + if (ping < 0) CommandOutput.warning("Ping is unavailable."); + else CommandOutput.send("Ping: " + ping + " ms"); + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RelationCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RelationCommand.java new file mode 100644 index 0000000..5f3dd72 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RelationCommand.java @@ -0,0 +1,160 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.relations.PlayerRelations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public abstract class RelationCommand implements ClientCommand { + protected abstract Set entries(PlayerRelations relations); + + protected abstract boolean add(PlayerRelations relations, String name); + + protected abstract boolean remove(PlayerRelations relations, String name); + + protected abstract String singular(); + + @Override + public final boolean execute(CommandContext ctx) { + PlayerRelations relations = PlayerRelations.get(); + String action = ctx.arg(0); + if (action == null || action.equalsIgnoreCase("list")) { + list(entries(relations)); + return true; + } + if (action.equalsIgnoreCase("clear")) { + int count = entries(relations).size(); + entries(relations).clear(); + save(relations); + CommandOutput.success("Cleared " + count + ' ' + singular() + " entries."); + return true; + } + + String name = ctx.arg(1); + if (name == null || name.isBlank()) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + + String stored = find(entries(relations), name); + boolean changed; + String result; + switch (action.toLowerCase(Locale.ROOT)) { + case "add" -> { + changed = stored == null && add(relations, name); + result = changed ? "Added " + name + " as " + singular() + '.' : name + " is already listed."; + } + case "remove", "delete", "del" -> { + changed = stored != null && remove(relations, stored); + result = changed ? "Removed " + stored + " from " + singular() + " list." : name + " is not listed."; + } + case "toggle" -> { + changed = stored == null ? add(relations, name) : remove(relations, stored); + result = stored == null ? "Added " + name + " as " + singular() + '.' : "Removed " + stored + " from " + singular() + " list."; + } + default -> { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + } + if (changed) save(relations); + if (changed) CommandOutput.success(result); + else CommandOutput.warning(result); + return true; + } + + @Override + public final List suggest(CommandContext ctx, int argIndex, String token) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + if (argIndex == 1) { + return List.of("list", "add", "remove", "toggle", "clear").stream() + .filter(value -> value.startsWith(lower)) + .toList(); + } + if (argIndex == 2) { + List names = new ArrayList<>(CommandUtils.suggestPlayers(ctx.mc(), token)); + for (String entry : entries(PlayerRelations.get())) { + if ((lower.isEmpty() || entry.toLowerCase(Locale.ROOT).startsWith(lower)) && !names.contains(entry)) { + names.add(entry); + } + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } + return List.of(); + } + + private void list(Set values) { + if (values.isEmpty()) { + CommandOutput.send("No " + singular() + " entries."); + return; + } + List sorted = values.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList(); + CommandOutput.send(Character.toUpperCase(singular().charAt(0)) + singular().substring(1) + " (" + sorted.size() + "): " + + String.join(", ", sorted)); + } + + private static String find(Set entries, String query) { + for (String entry : entries) { + if (entry.equalsIgnoreCase(query)) return entry; + } + return null; + } + + private static void save(PlayerRelations relations) { + ConfigSerializer.save(relations); + } + + @CommandInfo( + id = "friend", + aliases = {"friends", "f"}, + usage = "@friend [list|add|remove|toggle|clear] [player]", + descriptionKey = "command.friend.description" + ) + public static final class Friends extends RelationCommand { + @Override protected Set entries(PlayerRelations relations) { return relations.getFriends(); } + @Override protected boolean add(PlayerRelations relations, String name) { return relations.addFriend(name); } + @Override protected boolean remove(PlayerRelations relations, String name) { return relations.removeFriend(name); } + @Override protected String singular() { return "friend"; } + } + + @CommandInfo( + id = "enemy", + aliases = {"enemies", "e"}, + usage = "@enemy [list|add|remove|toggle|clear] [player]", + descriptionKey = "command.enemy.description" + ) + public static final class Enemies extends RelationCommand { + @Override protected Set entries(PlayerRelations relations) { return relations.getEnemies(); } + @Override protected boolean add(PlayerRelations relations, String name) { return relations.addEnemy(name); } + @Override protected boolean remove(PlayerRelations relations, String name) { return relations.removeEnemy(name); } + @Override protected String singular() { return "enemy"; } + } + + @CommandInfo( + id = "staff", + aliases = {"staffs", "admin"}, + usage = "@staff [list|add|remove|toggle|clear] [player]", + descriptionKey = "command.staff.description" + ) + public static final class Staff extends RelationCommand { + @Override protected Set entries(PlayerRelations relations) { return relations.getStaff(); } + @Override protected boolean add(PlayerRelations relations, String name) { return relations.addStaff(name); } + @Override protected boolean remove(PlayerRelations relations, String name) { return relations.removeStaff(name); } + @Override protected String singular() { return "staff"; } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RuntimeCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RuntimeCommand.java new file mode 100644 index 0000000..54b2b27 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/RuntimeCommand.java @@ -0,0 +1,78 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.runtime.ClientRuntime; +import dev.loki.lovisual.runtime.ClientRuntimeState; + +import java.nio.file.Path; +import java.util.List; + +@CommandInfo( + id = "runtime", + aliases = {"panic", "jarreplace"}, + usage = "@runtime [status|panic|resume|jar|source]", + descriptionKey = "command.runtime.description" +) +public final class RuntimeCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + String action = ctx.arg(0); + if (action == null || action.equalsIgnoreCase("status")) { + sendStatus(); + return true; + } + if (action.equalsIgnoreCase("panic")) { + ClientRuntime.toggleSoftPanic("runtime command"); + CommandOutput.send("Runtime state: " + ClientRuntime.state()); + return true; + } + if (action.equalsIgnoreCase("resume")) { + if (ClientRuntime.state() == ClientRuntimeState.SOFT_PANIC) { + ClientRuntime.exitSoftPanic("runtime command"); + } + CommandOutput.send("Runtime state: " + ClientRuntime.state()); + return true; + } + if (action.equalsIgnoreCase("jar") || action.equalsIgnoreCase("replace")) { + ClientRuntime.prepareJarReplacement("runtime command"); + CommandOutput.send("Client is fully disabled for safe JAR replacement. Restart Minecraft to use the new JAR."); + return true; + } + if (action.equalsIgnoreCase("source")) { + Path jar = ClientRuntime.loaderBridge().currentModJarPath(); + CommandOutput.send("Loader: standardFabric=" + ClientRuntime.loaderBridge().isStandardFabric() + + ", safeJarReplacement=" + ClientRuntime.loaderBridge().supportsSafeJarReplacement()); + CommandOutput.send("Current mod source: " + (jar == null ? "" : jar.toAbsolutePath())); + CommandOutput.send("LoVisual class source: " + + ClientRuntime.loaderBridge().describeClassSource("dev.loki.lovisual.LoVisual")); + return true; + } + CommandOutput.send("Usage: " + metadata().usage()); + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex != 1) return List.of(); + String lower = token == null ? "" : token.toLowerCase(); + return List.of("status", "panic", "resume", "jar", "source").stream() + .filter(value -> lower.isEmpty() || value.startsWith(lower)) + .toList(); + } + + private void sendStatus() { + for (String line : ClientRuntime.diagnostics().lines()) { + CommandOutput.send(line); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ServerInfoCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ServerInfoCommand.java new file mode 100644 index 0000000..7640f6c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ServerInfoCommand.java @@ -0,0 +1,48 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.util.player.NetworkStatsUtil; +import net.minecraft.client.multiplayer.ServerData; + +import java.util.Locale; + +@CommandInfo( + id = "serverinfo", + aliases = {"server", "sinfo"}, + usage = "@serverinfo", + descriptionKey = "command.serverinfo.description" +) +public final class ServerInfoCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + if (ctx.mc() == null || ctx.mc().level == null) { + CommandOutput.error("No world is loaded."); + return true; + } + if (ctx.mc().hasSingleplayerServer()) { + String name = ctx.mc().getSingleplayerServer() == null + ? "Singleplayer" + : ctx.mc().getSingleplayerServer().getWorldData().getLevelName(); + CommandOutput.send("World: " + name + " | Singleplayer | TPS: 20.00"); + return true; + } + ServerData server = ctx.mc().getCurrentServer(); + String address = server == null || server.ip == null ? "unknown" : server.ip; + String label = server == null || server.name == null ? "unknown" : server.name; + int ping = NetworkStatsUtil.getPing(ctx.mc()); + float tps = NetworkStatsUtil.getTps(ctx.mc()); + CommandOutput.send(String.format(Locale.ROOT, "Server: %s | %s | Ping: %s | TPS: %.2f", + label, address, ping < 0 ? "unknown" : ping + " ms", tps)); + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ToggleCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ToggleCommand.java new file mode 100644 index 0000000..d734e44 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/ToggleCommand.java @@ -0,0 +1,61 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.module.Module; + +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "toggle", + aliases = "t", + usage = "@toggle [on|off|toggle]", + descriptionKey = "command.toggle.description" +) +public final class ToggleCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + Module module = CommandUtils.findModule(ctx.arg(0)); + if (module == null) { + CommandOutput.error(ctx.arg(0) == null ? "Usage: " + metadata().usage() : "Module not found: " + ctx.arg(0)); + return true; + } + String stateArg = ctx.arg(1); + if (!CommandUtils.isState(stateArg)) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + boolean next = CommandUtils.parseState(stateArg, module.isEnabled()); + if (next && !module.isAvailable()) { + CommandOutput.error(module.getDisplayName() + " is unavailable: " + module.getAvailabilityReason()); + return true; + } + module.setEnabled(next); + if (module.isEnabled() == next) { + CommandOutput.success(module.getDisplayName() + ": " + (next ? "ON" : "OFF")); + } else { + CommandOutput.error("Could not change " + module.getDisplayName() + '.'); + } + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + if (argIndex == 1) return CommandUtils.suggestModules(token); + if (argIndex == 2) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + return List.of("on", "off", "toggle").stream().filter(value -> value.startsWith(lower)).toList(); + } + return List.of(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/TpsCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/TpsCommand.java new file mode 100644 index 0000000..899fdca --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/TpsCommand.java @@ -0,0 +1,31 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.util.player.NetworkStatsUtil; + +import java.util.Locale; + +@CommandInfo( + id = "tps", + aliases = "servertps", + usage = "@tps", + descriptionKey = "command.tps.description" +) +public final class TpsCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + float tps = NetworkStatsUtil.getTps(ctx.mc()); + CommandOutput.send(String.format(Locale.ROOT, "TPS: %.2f / 20.00", tps)); + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/UsernameCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/UsernameCommand.java new file mode 100644 index 0000000..9df5328 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/UsernameCommand.java @@ -0,0 +1,32 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; + +@CommandInfo( + id = "username", + aliases = {"name", "ign", "whoami"}, + usage = "@username", + descriptionKey = "command.username.description" +) +public final class UsernameCommand implements ClientCommand { + @Override + public boolean execute(CommandContext ctx) { + if (ctx.mc() == null || ctx.mc().player == null) { + CommandOutput.error("Player is unavailable."); + return true; + } + var profile = ctx.mc().player.getGameProfile(); + CommandOutput.send("Username: " + profile.name() + " | UUID: " + profile.id()); + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/XaeroWaypointCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/XaeroWaypointCommand.java new file mode 100644 index 0000000..0926ef6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/XaeroWaypointCommand.java @@ -0,0 +1,215 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.command.impl; + +import dev.loki.lovisual.compat.xaero.XaeroWaypointStore; +import dev.loki.lovisual.features.command.ClientCommand; +import dev.loki.lovisual.features.command.CommandContext; +import dev.loki.lovisual.features.command.CommandInfo; +import dev.loki.lovisual.features.command.CommandOutput; +import net.fabricmc.loader.api.FabricLoader; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +@CommandInfo( + id = "xaero", + aliases = {"xmark", "xwp", "waypoint", "marker"}, + usage = "@xaero here [name...] | @xaero [add] [name...] | @xaero remove [all|#|name] | @xaero list", + descriptionKey = "command.xaero.description" +) +public final class XaeroWaypointCommand implements ClientCommand { + private static final String CURRENT_COORDINATE = "~"; + + @Override + public boolean execute(CommandContext ctx) { + String first = ctx.arg(0); + if (first == null || first.isBlank()) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + + String action = first.toLowerCase(Locale.ROOT); + if (!isXaeroInstalled()) { + CommandOutput.error("Xaero's Minimap / World Map is not installed."); + return true; + } + + if ("clear".equals(action) || "remove".equals(action) || "delete".equals(action) || "rm".equals(action)) { + String selector = joinName(ctx.args(), 1); + XaeroWaypointStore.RemoveResult result = XaeroWaypointStore.remove(selector); + if (result.success()) { + CommandOutput.success(result.message()); + } else { + CommandOutput.error(result.message()); + } + return true; + } + if ("list".equals(action)) { + listMarkers(); + return true; + } + + boolean explicitAdd = "add".equals(action) || "mark".equals(action) || "set".equals(action); + if (isCurrentPositionKeyword(action)) { + return addAtCurrentPosition(ctx, 1); + } + if (explicitAdd && isCurrentPositionKeyword(ctx.arg(1))) { + return addAtCurrentPosition(ctx, 2); + } + + int offset = explicitAdd ? 1 : 0; + if (ctx.args().size() - offset < 3) { + CommandOutput.warning("Usage: " + metadata().usage()); + return true; + } + + boolean needsCurrentPosition = isCurrentCoordinate(ctx.arg(offset)) + || isCurrentCoordinate(ctx.arg(offset + 1)) + || isCurrentCoordinate(ctx.arg(offset + 2)); + if (needsCurrentPosition && (ctx.mc() == null || ctx.mc().player == null)) { + CommandOutput.error("Current player position is unavailable."); + return true; + } + + int currentX = needsCurrentPosition ? ctx.mc().player.getBlockX() : 0; + int currentY = needsCurrentPosition ? ctx.mc().player.getBlockY() : 0; + int currentZ = needsCurrentPosition ? ctx.mc().player.getBlockZ() : 0; + Integer x = parseCoordinate(ctx.arg(offset), currentX); + Integer y = parseCoordinate(ctx.arg(offset + 1), currentY); + Integer z = parseCoordinate(ctx.arg(offset + 2), currentZ); + if (x == null || y == null || z == null) { + CommandOutput.error("Invalid coordinates. Use an integer or ~ for the current coordinate."); + return true; + } + + String name = joinName(ctx.args(), offset + 3); + return addMarker(x, y, z, name); + } + + private static boolean addAtCurrentPosition(CommandContext ctx, int nameOffset) { + if (ctx.mc() == null || ctx.mc().player == null) { + CommandOutput.error("Current player position is unavailable."); + return true; + } + return addMarker( + ctx.mc().player.getBlockX(), + ctx.mc().player.getBlockY(), + ctx.mc().player.getBlockZ(), + joinName(ctx.args(), nameOffset) + ); + } + + private static boolean addMarker(int x, int y, int z, String name) { + XaeroWaypointStore.AddResult result = XaeroWaypointStore.addFromCurrentDimension(x, y, z, name, true); + if (result.success()) { + CommandOutput.success(result.message()); + } else { + CommandOutput.error(result.message()); + } + return true; + } + + @Override + public List suggest(CommandContext ctx, int argIndex, String token) { + String lower = token == null ? "" : token.toLowerCase(Locale.ROOT); + if (argIndex == 1) { + List out = new ArrayList<>(); + for (String value : List.of("add", "here", "remove", "clear", "list", CURRENT_COORDINATE)) { + if (lower.isEmpty() || value.startsWith(lower)) out.add(value); + } + return out; + } + if (argIndex == 2) { + String action = ctx.arg(0); + if (action != null && List.of("remove", "delete", "rm", "clear").contains(action.toLowerCase(Locale.ROOT))) { + List out = new ArrayList<>(); + for (String value : List.of("all")) { + if (lower.isEmpty() || value.startsWith(lower)) out.add(value); + } + List markers = XaeroWaypointStore.markers(); + for (int i = 1; i <= markers.size(); i++) { + String value = Integer.toString(i); + if (lower.isEmpty() || value.startsWith(lower)) out.add(value); + } + return out; + } + } + if (isCoordinateArgument(ctx, argIndex)) { + return CURRENT_COORDINATE.startsWith(lower) ? List.of(CURRENT_COORDINATE) : List.of(); + } + return List.of(); + } + + private static boolean isCoordinateArgument(CommandContext ctx, int argIndex) { + String first = ctx.arg(0); + if (first == null) return false; + String action = first.toLowerCase(Locale.ROOT); + if (isCurrentPositionKeyword(action)) return false; + if ("add".equals(action) || "mark".equals(action) || "set".equals(action)) { + return !isCurrentPositionKeyword(ctx.arg(1)) && argIndex >= 2 && argIndex <= 4; + } + if (List.of("remove", "delete", "rm", "clear", "list").contains(action)) return false; + return argIndex >= 1 && argIndex <= 3; + } + + private static void listMarkers() { + List markers = XaeroWaypointStore.markers(); + if (markers.isEmpty()) { + CommandOutput.send("No LoVisual Xaero markers."); + return; + } + CommandOutput.send("LoVisual Xaero markers: " + markers.size()); + int index = 1; + for (XaeroWaypointStore.Marker marker : markers) { + CommandOutput.send(index++ + ". " + marker.name() + " " + + marker.dimensionId() + " " + + marker.x() + " " + marker.y() + " " + marker.z()); + } + } + + private static boolean isXaeroInstalled() { + FabricLoader loader = FabricLoader.getInstance(); + return loader.isModLoaded("xaerominimap") || loader.isModLoaded("xaeroworldmap"); + } + + static Integer parseCoordinate(String value, int currentCoordinate) { + if (value == null || value.isBlank()) return null; + if (isCurrentCoordinate(value)) return currentCoordinate; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static boolean isCurrentCoordinate(String value) { + return CURRENT_COORDINATE.equals(value == null ? null : value.trim()); + } + + private static boolean isCurrentPositionKeyword(String value) { + if (value == null) return false; + return switch (value.toLowerCase(Locale.ROOT)) { + case "here", "current", "position", "pos" -> true; + default -> false; + }; + } + + private static String joinName(List args, int start) { + if (args == null || start >= args.size()) return null; + StringBuilder sb = new StringBuilder(); + for (int i = start; i < args.size(); i++) { + String part = args.get(i); + if (part == null || part.isBlank()) continue; + if (sb.length() > 0) sb.append(' '); + sb.append(part.trim()); + } + return sb.length() == 0 ? null : sb.toString(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatHoverCache.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatHoverCache.java new file mode 100644 index 0000000..4665580 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatHoverCache.java @@ -0,0 +1,419 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import com.google.gson.*; +import com.mojang.serialization.JsonOps; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.world.item.ItemStack; +import dev.loki.lovisual.config.ConfigPaths; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Per-key cache for ShowItem/ShowEntity hover payloads to persist across sessions. + * Stores compact deduped entries; keyed by current chat store key (dimension/server). + */ +public final class BetterChatHoverCache { + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + private static volatile HolderLookup.Provider cachedLookup; + private static volatile RegistryOps cachedOps; + private final Map itemCache = new HashMap<>(); + private final Map entityCache = new HashMap<>(); + private final Map decodedItemCache = new HashMap<>(); + private final Map itemLookupByName = new HashMap<>(); + private final Map entityLookupByName = new HashMap<>(); + private final java.util.Set loadedItemKeys = new java.util.HashSet<>(); + private final java.util.Set loadedEntityKeys = new java.util.HashSet<>(); + private boolean loadedFromDisk = false; + private boolean itemLookupIndexBuilt = false; + private boolean entityLookupIndexBuilt = false; + private BetterChatHoverCache() { + } + + public static BetterChatHoverCache load(String key) { + File f = fileFor(key); + BetterChatHoverCache cache = new BetterChatHoverCache(); + if (!f.exists()) return cache; + try (FileReader r = new FileReader(f, StandardCharsets.UTF_8)) { + JsonObject obj = GSON.fromJson(r, JsonObject.class); + cache.loadedFromDisk = true; + if (obj != null && obj.has("items")) { + JsonArray arr = obj.getAsJsonArray("items"); + arr.forEach(el -> { + if (!el.isJsonObject()) return; + JsonObject o = el.getAsJsonObject(); + String keyId = o.has("k") ? o.get("k").getAsString() : null; + String id = o.has("id") ? o.get("id").getAsString() : null; + String comp = o.has("comp") ? o.get("comp").getAsString() : null; + if (keyId != null && id != null && comp != null) { + cache.itemCache.put(keyId, new ItemEntry(id, comp)); + cache.loadedItemKeys.add(keyId); + } + }); + } + if (obj != null && obj.has("entities")) { + JsonArray arr = obj.getAsJsonArray("entities"); + arr.forEach(el -> { + if (!el.isJsonObject()) return; + JsonObject o = el.getAsJsonObject(); + String keyId = o.has("k") ? o.get("k").getAsString() : null; + String name = o.has("name") ? o.get("name").getAsString() : ""; + String type = o.has("type") ? o.get("type").getAsString() : ""; + String uuid = o.has("uuid") ? o.get("uuid").getAsString() : null; + if (keyId != null && uuid != null && !name.isEmpty()) { + try { + Identifier tid = Identifier.tryParse(type); + if (tid != null) { + var et = BuiltInRegistries.ENTITY_TYPE.getValue(tid); + if (et != net.minecraft.world.entity.EntityTypes.PLAYER) { + String def = et.getDescription().getString().trim(); + if (!def.isEmpty() && def.equalsIgnoreCase(name.trim())) { + return; + } + } + } + } catch (Exception ignored) { + } + cache.entityCache.put(keyId, new EntityEntry(name, type, uuid)); + cache.loadedEntityKeys.add(keyId); + } + }); + } + } catch (Exception e) { + DebugLog.error("BetterChatHoverCache load failed for %s", e, key); + } + return cache; + } + + private static File fileFor(String key) { + Minecraft mc = Minecraft.getInstance(); + File dir = mc.gameDirectory.toPath() + .resolve(ConfigPaths.root()) + .resolve("betterchat") + .resolve("cache") + .toFile(); + return new File(dir, key + ".json"); + } + + private static HolderLookup.Provider lookup() { + Minecraft mc = Minecraft.getInstance(); + if (mc == null) return null; + ClientPacketListener net = mc.getConnection(); + if (net != null) return net.registryAccess(); + if (mc.level != null) return mc.level.registryAccess(); + return null; + } + + private static RegistryOps ops() { + HolderLookup.Provider lookup = lookup(); + if (lookup == null) { + return null; + } + RegistryOps cached = cachedOps; + if (cached != null && cachedLookup == lookup) { + return cached; + } + synchronized (BetterChatHoverCache.class) { + if (cachedOps == null || cachedLookup != lookup) { + cachedLookup = lookup; + cachedOps = RegistryOps.create(JsonOps.INSTANCE, lookup); + } + return cachedOps; + } + } + + private static String normalizeLookup(String raw) { + return raw == null ? "" : raw.trim().toLowerCase(); + } + + public void save(String key) { + try { + File f = fileFor(key); + f.getParentFile().mkdirs(); + JsonObject root = new JsonObject(); + JsonArray items = new JsonArray(); + for (var e : itemCache.entrySet()) { + JsonObject o = new JsonObject(); + o.addProperty("k", e.getKey()); + o.addProperty("id", e.getValue().getId()); + o.addProperty("comp", e.getValue().componentsJson()); + items.add(o); + } + JsonArray ents = new JsonArray(); + for (var e : entityCache.entrySet()) { + JsonObject o = new JsonObject(); + o.addProperty("k", e.getKey()); + o.addProperty("name", e.getValue().name()); + o.addProperty("type", e.getValue().typeId()); + o.addProperty("uuid", e.getValue().uuid()); + ents.add(o); + } + root.add("items", items); + root.add("entities", ents); + try (FileWriter w = new FileWriter(f, StandardCharsets.UTF_8)) { + GSON.toJson(root, w); + } + } catch (Exception e) { + DebugLog.error("BetterChatHoverCache save failed for %s", e, key); + } + } + + public boolean putItem(String keyId, ItemStack stack) { + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheItems()) return false; + if (keyId == null || stack == null || stack.isEmpty()) return false; + try { + String itemId = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + RegistryOps ops = ops(); + if (ops == null) return false; + var encoded = ItemStack.CODEC.encodeStart(ops, stack.copy()); + var res = encoded.result(); + if (res.isEmpty()) return false; + String compJson = res.get().toString(); + if (compJson.length() > 20000) return false; + ItemEntry entry = new ItemEntry(itemId, compJson); + ItemEntry prev = itemCache.put(keyId, entry); + decodedItemCache.put(keyId, stack.copy()); + itemLookupIndexBuilt = false; + clamp(cacheLimit()); + return !entry.equals(prev); + } catch (Exception e) { + DebugLog.error("BetterChatHoverCache putItem failed", e); + return false; + } + } + + public boolean putEntity(String keyId, HoverEvent.EntityTooltipInfo content) { + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheEntities()) return false; + if (keyId == null || content == null) return false; + + String name = content.name.map(Component::getString).orElse("").trim(); + if (name.isEmpty()) return false; + + if (content.type != net.minecraft.world.entity.EntityTypes.PLAYER) { + String def = content.type.getDescription().getString().trim(); + if (!def.isEmpty() && def.equalsIgnoreCase(name)) { + return false; + } + } + + String type = BuiltInRegistries.ENTITY_TYPE.getKey(content.type).toString(); + String uuid = content.uuid.toString(); + EntityEntry entry = new EntityEntry(name, type, uuid); + EntityEntry prev = entityCache.put(keyId, entry); + entityLookupIndexBuilt = false; + clamp(cacheLimit()); + return !entry.equals(prev); + } + + public ItemStack getItem(String keyId) { + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheItems()) return ItemStack.EMPTY; + ItemEntry e = itemCache.get(keyId); + if (e == null) return ItemStack.EMPTY; + ItemStack cached = decodedItemCache.get(keyId); + if (cached != null && !cached.isEmpty()) { + return cached.copy(); + } + try { + RegistryOps ops = ops(); + if (ops == null) return ItemStack.EMPTY; + var parsed = ItemStack.CODEC.parse(ops, JsonParser.parseString(e.componentsJson())); + if (parsed.result().isPresent()) { + ItemStack decoded = parsed.result().get(); + decodedItemCache.put(keyId, decoded.copy()); + return decoded; + } + } catch (Exception ex) { + DebugLog.error("BetterChatHoverCache getItem decode failed", ex); + } + return ItemStack.EMPTY; + } + + public HoverEvent.EntityTooltipInfo getEntity(String keyId) { + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheEntities()) return null; + EntityEntry e = entityCache.get(keyId); + if (e == null) return null; + try { + return new HoverEvent.EntityTooltipInfo( + BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.tryParse(e.typeId())), + UUID.fromString(e.uuid()), + e.name().isEmpty() ? null : Component.literal(e.name()) + ); + } catch (Exception ex) { + DebugLog.error("BetterChatHoverCache getEntity build failed", ex); + return null; + } + } + + private void clamp(int limit) { + int max = Math.max(64, limit); + while (itemCache.size() > max) { + var it = itemCache.keySet().iterator(); + if (it.hasNext()) { + String key = it.next(); + it.remove(); + decodedItemCache.remove(key); + itemLookupIndexBuilt = false; + } + } + while (entityCache.size() > max) { + var it = entityCache.keySet().iterator(); + if (it.hasNext()) { + it.next(); + it.remove(); + entityLookupIndexBuilt = false; + } + } + } + + private int cacheLimit() { + BetterChat cfg = BetterChat.get(); + return cfg != null ? cfg.cacheLimit() : 512; + } + + public ItemStack recoverItemById(String itemId) { + if (itemId == null) return ItemStack.EMPTY; + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheItems()) return ItemStack.EMPTY; + for (var entry : itemCache.entrySet()) { + if (itemId.equals(entry.getValue().getId())) { + return getItem(entry.getKey()); + } + } + return ItemStack.EMPTY; + } + + public ItemStack getItemByKeyOrId(String keyId, String itemId) { + ItemStack byKey = getItem(keyId); + if (!byKey.isEmpty()) return byKey; + return recoverItemById(itemId); + } + + public boolean hasItemId(String itemId) { + if (itemId == null) return false; + return itemCache.values().stream().anyMatch(e -> itemId.equals(e.getId())); + } + + public boolean wasLoadedFromDisk() { + return loadedFromDisk; + } + + public boolean wasItemKeyLoaded(String keyId) { + return loadedItemKeys.contains(keyId); + } + + public boolean wasEntityKeyLoaded(String keyId) { + return loadedEntityKeys.contains(keyId); + } + + public ItemLookup findItemByDisplayNameWithMeta(String displayName) { + if (displayName == null || displayName.isEmpty()) return new ItemLookup(ItemStack.EMPTY, false); + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheItems()) return new ItemLookup(ItemStack.EMPTY, false); + String needle = normalizeLookup(displayName); + if (needle.isEmpty()) return new ItemLookup(ItemStack.EMPTY, false); + ensureItemLookupIndex(); + ItemLookup lookup = itemLookupByName.get(needle); + if (lookup == null || lookup.stack().isEmpty()) { + return new ItemLookup(ItemStack.EMPTY, false); + } + return new ItemLookup(lookup.stack().copy(), lookup.loadedFromFile()); + } + + public ItemStack findItemByDisplayName(String displayName) { + return findItemByDisplayNameWithMeta(displayName).stack(); + } + + public EntityLookup findEntityByNameWithMeta(String name) { + if (name == null || name.isEmpty()) return new EntityLookup(null, false); + BetterChat cfg = BetterChat.get(); + if (cfg != null && !cfg.cacheEntities()) return new EntityLookup(null, false); + String needle = normalizeLookup(name); + if (needle.isEmpty()) return new EntityLookup(null, false); + ensureEntityLookupIndex(); + EntityLookup lookup = entityLookupByName.get(needle); + return lookup != null ? lookup : new EntityLookup(null, false); + } + + public HoverEvent.EntityTooltipInfo findEntityByName(String name) { + return findEntityByNameWithMeta(name).content(); + } + + private void ensureItemLookupIndex() { + if (itemLookupIndexBuilt) return; + itemLookupByName.clear(); + for (var kv : itemCache.entrySet()) { + String key = kv.getKey(); + ItemStack stack = getItem(key); + if (stack.isEmpty()) continue; + String normalized = normalizeLookup(stack.getHoverName().getString()); + if (normalized.isEmpty() || itemLookupByName.containsKey(normalized)) continue; + boolean loaded = loadedFromDisk && loadedItemKeys.contains(key); + itemLookupByName.put(normalized, new ItemLookup(stack.copy(), loaded)); + } + itemLookupIndexBuilt = true; + } + + private void ensureEntityLookupIndex() { + if (entityLookupIndexBuilt) return; + entityLookupByName.clear(); + for (var kv : entityCache.entrySet()) { + String key = kv.getKey(); + EntityEntry entry = kv.getValue(); + String normalized = normalizeLookup(entry.name()); + if (normalized.isEmpty() || entityLookupByName.containsKey(normalized)) continue; + try { + HoverEvent.EntityTooltipInfo content = new HoverEvent.EntityTooltipInfo( + BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.tryParse(entry.typeId())), + UUID.fromString(entry.uuid()), + entry.name().isEmpty() ? null : Component.literal(entry.name()) + ); + boolean loaded = loadedFromDisk && loadedEntityKeys.contains(key); + entityLookupByName.put(normalized, new EntityLookup(content, loaded)); + } catch (Exception ignored) { + } + } + entityLookupIndexBuilt = true; + } + + public record ItemEntry(String id, String componentsJson) { + public String getId() { + return id; + } + } + + public record EntityEntry(String name, String typeId, String uuid) { + } + + public record ItemLookup(ItemStack stack, boolean loadedFromFile) { + } + + public record EntityLookup(HoverEvent.EntityTooltipInfo content, boolean loadedFromFile) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatMessageGrouping.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatMessageGrouping.java new file mode 100644 index 0000000..2dd34f5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatMessageGrouping.java @@ -0,0 +1,86 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import net.minecraft.network.chat.Component; + +import java.util.List; + +final class BetterChatMessageGrouping { + private static final String BOX_PREFIXES = "┏┓┗┛┃│║╔╗╚╝╭╮╰╯┌┐└┘┣┫├┤╠╣"; + + private BetterChatMessageGrouping() { + } + + static int[] groupIds(List messages) { + if (messages == null || messages.isEmpty()) { + return new int[0]; + } + + int[] groups = new int[messages.size()]; + int currentGroup = -1; + boolean inBoxBlock = false; + for (int i = 0; i < messages.size(); i++) { + boolean boxLine = isBoxLine(messages.get(i)); + boolean emptyBoxSpacer = isBlank(messages.get(i)) + && (isBoxLineAt(messages, i - 1) || isBoxLineAt(messages, i + 1)); + boolean attachToBox = boxLine || emptyBoxSpacer; + + if (attachToBox) { + if (!inBoxBlock) { + currentGroup++; + inBoxBlock = true; + } + } else { + currentGroup++; + inBoxBlock = false; + } + groups[i] = currentGroup; + } + return groups; + } + + private static boolean isBoxLineAt(List messages, int index) { + return index >= 0 && index < messages.size() && isBoxLine(messages.get(index)); + } + + private static boolean isBoxLine(ChatLine line) { + String text = string(line).stripLeading(); + if (text.isEmpty()) return false; + int cp = text.codePointAt(0); + if (BOX_PREFIXES.indexOf(cp) >= 0) return true; + return isMostlyBoxDrawing(text); + } + + private static boolean isMostlyBoxDrawing(String text) { + int drawing = 0; + int visible = 0; + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + i += Character.charCount(cp); + if (Character.isWhitespace(cp)) continue; + visible++; + if (isBoxDrawing(cp)) drawing++; + } + return visible > 0 && drawing >= Math.max(1, visible - 1); + } + + private static boolean isBoxDrawing(int cp) { + return (cp >= 0x2500 && cp <= 0x257F) || cp == '═' || cp == '║'; + } + + private static boolean isBlank(ChatLine line) { + return string(line).isBlank(); + } + + private static String string(ChatLine line) { + if (line == null) return ""; + Component text = line.text(); + return text == null ? "" : text.getString(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatRenderer.java new file mode 100644 index 0000000..50286a3 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatRenderer.java @@ -0,0 +1,2870 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.loki.lovisual.mixins.accessors.SuggestionWindowAccessor; +import dev.loki.lovisual.render.engine.text.VanillaTextRenderer; +import dev.loki.lovisual.util.item.IllegalItemUtil; +import dev.loki.lovisual.util.item.TopEnchantUtil; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.client.gui.screens.ConfirmLinkScreen; +import net.minecraft.client.gui.screens.inventory.tooltip.DefaultTooltipPositioner; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.command.CommandManager; +import dev.loki.lovisual.features.command.CommandOutput; +import dev.loki.lovisual.features.gui.chat.ChatHoverUtil.HoverTip; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import dev.loki.lovisual.features.gui.hud.nondraggable.impl.BetterTooltips; +import dev.loki.lovisual.mixins.accessors.ChatScreenAccessor; +import dev.loki.lovisual.mixins.accessors.TextFieldWidgetAccessor; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextGlyphFallback; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ClipFunction; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.text.ChatNameUtil; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.util.text.TextSelection; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.lwjgl.glfw.GLFW.GLFW_PRESS; +import static org.lwjgl.glfw.GLFW.glfwGetKey; +import static dev.loki.lovisual.features.theme.Theme.theme; + +public enum BetterChatRenderer { + ; + private static final int MAX_MESSAGE_CHARS = 256; // vanilla chat input limit + private static final float PADDING = 8f; + private static final float LINE_SPACING = 3f; + private static final float RADIUS = 24f; + private static final float MESSAGE_PAD_X = 8f; + private static final float MESSAGE_PAD_Y = 5f; + private static final float MESSAGE_GAP = 4f; + private static final float MESSAGE_INPUT_GAP = 8f; + private static final float MESSAGE_RADIUS = 9f; + private static final float MESSAGE_TS_GAP = 8f; + private static final float INPUT_RADIUS = 12f; + private static final float SEARCH_ICON_WIDTH = 34f; + private static final float SEARCH_ICON_HEIGHT = 22f; + private static final float NEW_MESSAGE_REVEAL_SECONDS = 0.34f; + private static final float NEW_MESSAGE_SLIDE_PX = 18f; + private static final int MAX_HISTORY = 32000; + private static final float CHAT_TOOLTIP_OFFSET_X = 6f; + private static final float CHAT_TOOLTIP_OFFSET_Y = 12f; + private static final RenderColor TMP_COLOR = new RenderColor(0xFFFFFFFF); + private static final java.util.IdentityHashMap MESSAGE_LAYOUT_CACHE = new java.util.IdentityHashMap<>(); + private static final java.util.IdentityHashMap> SEGMENT_CACHE = new java.util.IdentityHashMap<>(); + private static final java.util.IdentityHashMap COMMAND_LINE_CACHE = new java.util.IdentityHashMap<>(); + private static final TextSelection selection = new TextSelection(); + private static final double DRAG_SELECT_THRESHOLD = 4.0; // px + private static Renderer2D renderer; + private static float lastRenderWidth = 0f; + private static float lastRenderHeight = 0f; + private static int scrollOffsetLines = 0; + private static float smoothScrollOffsetLines = 0f; + private static int maxScrollLines = 0; + private static float lastFontSizeForUi = 16f; + private static List lastMessages = Collections.emptyList(); + private static boolean draggingScrollbar = false; + private static float sbTrackX, sbTrackY, sbTrackW, sbTrackH; + private static float sbThumbY, sbThumbH; + private static float sbDragOffset = 0f; + private static long lastScrollInteractionMs = 0L; + private static boolean sbEnabled = false; + private static int sbTotalLines = 0; + private static int sbVisibleLines = 0; + private static float inputBoxX = 0f, inputBoxY = 0f, inputBoxW = 0f, inputBoxH = 0f; + private static boolean hasInputBox = false; + // Suggest UI hitbox + private static boolean suggestActive = false; + private static float suggestX, suggestY, suggestW, suggestH, suggestItemH; + private static float suggestRowsX, suggestRowsY, suggestRowsW, suggestRowsH; + private static int suggestStart, suggestVisible, suggestTotal; + private static net.minecraft.client.gui.components.CommandSuggestions.SuggestionsList suggestWindow; + private static List suggestEntries = Collections.emptyList(); + private static boolean lastHoverWasOutsideSuggest = true; + private static boolean suggestHasScrollbar = false; + private static boolean suggestDraggingScrollbar = false; + private static float suggestTrackX, suggestTrackY, suggestTrackW, suggestTrackH, suggestThumbH; + private static int suggestCurrentStart = -1; + private static int suggestCurrentSelection = -1; + private static double lastMouseFx = 0.0; + private static double lastMouseFy = 0.0; + private static boolean lastMouseValid = false; + private static boolean tooltipCaptured = false; + private static String lastSuggestInput = ""; + private static int lastSuggestCursor = -1; + private static int lastSuggestSelStart = -1; + private static int lastSuggestSelEnd = -1; + private static CommandSuggestorBridge.SuggestionSnapshot lastSnapshot = null; + private static long debugPerfWindowStartNs = 0L; + private static long debugPerfAccumLayoutNs = 0L; + private static long debugPerfAccumBuildNs = 0L; + private static int debugPerfAccumFrames = 0; + private static FrameLayout frame = FrameLayout.empty(); + private static ContextMenu contextMenu = ContextMenu.closed(); + private static boolean selecting = false; + private static boolean searchHit = false; + private static float searchRectX = 0f, searchRectY = 0f, searchRectW = 0f, searchRectH = 0f; + private static boolean searchHotkeyDown = false; + private static float chatHitX = 0f, chatHitY = 0f, chatHitW = 0f, chatHitH = 0f; + private static String hoverEntityUuid = null; + private static ChatScreen storedScreen; + private static BetterChatStore previewStore; + private static int lastSuggestionHash = 0; + private static boolean leftPending = false; + private static double leftDownFx = 0.0, leftDownFy = 0.0; + private static int leftDownMsgIndex = -1; + private static int leftDownCharIndex = -1; + private static Style leftDownStyle = null; + + public static void markLayoutDirty() { + } + + public static void renderEngine(Renderer2D rendererIn, + TextRenderer fallback, + GuiGraphicsExtractor ctx, + int screenW, + float baseX, + float baseY) { + SystemCursor.beginFrame(ctx); + try { + renderEngineInternal(rendererIn, fallback, ctx, screenW, baseX, baseY); + } finally { + SystemCursor.endFrame(); + } + } + + private static void renderEngineInternal(Renderer2D rendererIn, + TextRenderer fallback, + GuiGraphicsExtractor ctx, + int screenW, + float baseX, + float baseY) { + renderer = rendererIn; + BetterChat settings = BetterChat.get(); + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (settings == null || (!settings.isEnabled() && !preview)) { + lastRenderWidth = 0f; + lastRenderHeight = 0f; + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc == null || (mc.player == null && !preview)) { + lastRenderWidth = 0f; + lastRenderHeight = 0f; + return; + } + if (!preview && mc.isPaused()) { + selection.clear(); + contextMenu = ContextMenu.closed(); + selecting = false; + lastRenderWidth = 0f; + lastRenderHeight = 0f; + return; + } + + BetterChatStore store = (preview && mc.player == null) + ? buildPreviewStore() + : BetterChatStoreManager.getActiveStore(mc); + if (store == null) { + if (preview) { + store = buildPreviewStore(); + } else { + lastRenderWidth = 0f; + lastRenderHeight = 0f; + return; + } + } + + boolean chatOpen = ClientScreen.current() instanceof ChatScreen; + boolean activeChatSurface = chatOpen || preview; + if (!chatOpen && store.size() == 0) { + if (preview) { + store = buildPreviewStore(); + } else { + selection.clear(); + contextMenu = ContextMenu.closed(); + lastRenderWidth = 0f; + lastRenderHeight = 0f; + return; + } + } + if (!chatOpen) { + selection.clear(); + contextMenu = ContextMenu.closed(); + selecting = false; + storedScreen = null; + draggingScrollbar = false; + sbEnabled = false; + scrollOffsetLines = 0; + BetterChatSearch.deactivate(); + resetSuggestionTracking(); + } + if (chatOpen && ctx != null) { + BetterTooltips.beginTooltipFrame(); + BetterTooltips.setDrawContext(ctx); + } + tooltipCaptured = false; + + int visibleLimit = Math.max(1, chatOpen ? settings.maxLines() : Math.min(settings.maxLines(), 12)); + List commandOutputMessages = settings.hideVanilla() + ? CommandOutput.recentMessages() + : List.of(); + int totalMessages = store.size() + commandOutputMessages.size(); + boolean autoFollow = scrollOffsetLines == 0; + boolean tsEnabled = settings.timestampEnabled(); + boolean tsSeconds = settings.timestampSeconds(); + int tsColor = settings.timestampColor(); + boolean tsActiveOnly = settings.timestampToggles().get("active_only"); + boolean tsTipDate = settings.timestampToggles().get("hover_date"); + boolean tsTipUnix = settings.timestampToggles().get("hover_unix"); + boolean showTs = tsEnabled && (!tsActiveOnly || chatOpen); + + List messages; + if (BetterChatSearch.hasQuery() && BetterChatSearch.results() != null && chatOpen) { + List res = BetterChatSearch.results(); + totalMessages = res.size(); + int maxScrollSearch = Math.max(0, totalMessages - visibleLimit); + scrollOffsetLines = Mth.clamp(scrollOffsetLines, 0, maxScrollSearch); + int tailBudget = Math.min(totalMessages, Math.max(visibleLimit + scrollOffsetLines + 20, visibleLimit + 10)); + int endIdx = Math.min(totalMessages, Math.max(0, totalMessages - scrollOffsetLines)); + int startIdx = Math.max(0, endIdx - tailBudget); + if (startIdx > endIdx) startIdx = endIdx; + messages = res.subList(startIdx, endIdx); + } else { + int tailBudget = chatOpen + ? Math.max(visibleLimit + scrollOffsetLines + 80, visibleLimit) + : Math.max(visibleLimit + 8, visibleLimit); + int needed = Math.min(MAX_HISTORY, Math.min(totalMessages, tailBudget)); + messages = mergeCommandOutput(store.tail(Math.min(store.size(), needed)), commandOutputMessages, needed); + } + lastMessages = messages; + int[] messageGroups = BetterChatMessageGrouping.groupIds(messages); + + float fontSize = 16f * settings.fontScale(); + float width = computeWidth(screenW, settings.widthRatio()); + float tsWidth = 0f; + if (showTs) { + String sampleTs = tsSeconds ? "00:00:00" : "00:00"; + tsWidth = textWidth(getInterRegular(), sampleTs, Math.max(9f, fontSize * 0.72f)) + MESSAGE_TS_GAP; + } + float contentWidth = Math.max(32f, width - PADDING * 2f - MESSAGE_PAD_X * 2f - tsWidth); + lastFontSizeForUi = fontSize; + + int lineTarget = visibleLimit + scrollOffsetLines + Math.max(10, visibleLimit / 2) + 2; + long perfT0 = System.nanoTime(); + LayoutResult layoutResult = layoutLinesTailCached(messages, messageGroups, fontSize, contentWidth, lineTarget); + long perfT1 = System.nanoTime(); + List allLines = layoutResult.lines(); + float lineHeight = fontSize + LINE_SPACING; + float boxHeight; + List visible; + int localStart; + hasInputBox = false; // reset per-frame before renderInput fills it + float lineYOffset = 0f; + int targetVisibleForBox = visibleLimit; + if (allLines.isEmpty()) { + maxScrollLines = 0; + sbTotalLines = 0; + sbVisibleLines = 0; + boxHeight = 0f; + visible = Collections.emptyList(); + smoothScrollOffsetLines = 0f; + } else { + int lineCount = allLines.size(); + int observedMessages = Math.max(1, layoutResult.messagesSeen()); + float avgLinesPerMsg = Math.max(1f, (float) lineCount / (float) observedMessages); + int estimatedTotalLines = Math.max(lineCount, (int) Math.ceil(avgLinesPerMsg * totalMessages)); + maxScrollLines = Math.max(0, estimatedTotalLines - visibleLimit); + scrollOffsetLines = Mth.clamp(scrollOffsetLines, 0, maxScrollLines); + if (autoFollow) { + smoothScrollOffsetLines = 0f; + } else { + smoothScrollOffsetLines = Mth.clamp(smoothScrollOffsetLines, 0f, maxScrollLines); + smoothScrollOffsetLines = AnimationUtility.approach( + smoothScrollOffsetLines, + scrollOffsetLines, + draggingScrollbar ? 0.55f : 0.22f + ); + smoothScrollOffsetLines = AnimationUtility.snap( + smoothScrollOffsetLines, + scrollOffsetLines, + draggingScrollbar ? 0.01f : 0.035f + ); + } + + int lastMsgIndex = messages.isEmpty() ? -1 : messages.size() - 1; + int linesInLastMsg = 0; + if (lastMsgIndex >= 0) { + for (int i = lineCount - 1; i >= 0; i--) { + if (allLines.get(i).messageIndex() == lastMsgIndex) { + linesInLastMsg++; + } else if (linesInLastMsg > 0) { + break; + } + } + } + int targetVisible = autoFollow ? Math.max(visibleLimit, linesInLastMsg) : visibleLimit; + targetVisibleForBox = Math.max(1, Math.min(targetVisible, lineCount)); + + float globalStartFloat = autoFollow + ? Math.max(0f, lineCount - targetVisible) + : Math.max(0f, lineCount - visibleLimit - smoothScrollOffsetLines); + int globalStart = Math.max(0, (int) Math.floor(globalStartFloat) - (autoFollow ? 0 : 1)); + int viewportStart = autoFollow + ? Math.max(0, lineCount - targetVisibleForBox) + : Mth.clamp((int) Math.floor(globalStartFloat), 0, Math.max(0, lineCount - 1)); + int viewportEnd = Math.min(lineCount, viewportStart + targetVisibleForBox); + int groupsForBox = countMessageGroups(allLines.subList(viewportStart, viewportEnd), targetVisibleForBox); + + localStart = Math.max(0, Math.min(globalStart, Math.max(0, lineCount - 1))); + int endLine = Math.min(lineCount, localStart + targetVisibleForBox + (autoFollow ? 0 : 3)); + localStart = expandStartToGroup(allLines, localStart); + endLine = expandEndToGroup(allLines, endLine); + visible = allLines.subList(localStart, endLine); + sbTotalLines = lineCount; + sbVisibleLines = targetVisibleForBox; + + boxHeight = messageStackHeight(targetVisibleForBox, groupsForBox, lineHeight); + if (autoFollow) { + int actualGroups = countMessageGroups(visible, visible.size()); + float actualHeight = messageStackHeight(visible.size(), actualGroups, lineHeight); + // expandStartToGroup() may prepend overscan lines. Keep the newest bubble pinned to + // the viewport bottom instead of allowing those prepended lines to push it down. + lineYOffset = boxHeight - actualHeight; + } else { + lineYOffset = (localStart - globalStartFloat) * lineHeight; + } + } + float stableBoxHeight = messageStackHeight(visibleLimit, visibleLimit, lineHeight); + float fixedInputY = baseY + stableBoxHeight + MESSAGE_INPUT_GAP; + float frameY = baseY + stableBoxHeight - boxHeight; + float visualTop = Math.min(baseY, frameY); + float visualBottom = chatOpen ? fixedInputY + Math.max(36f, fontSize + 16f) : frameY + boxHeight; + float totalHeight = Math.max(0f, visualBottom - visualTop); + lastRenderWidth = width; + lastRenderHeight = totalHeight; + + long perfT2Start = System.nanoTime(); + frame = FrameLayout.build(visible, baseX, frameY, width, boxHeight, fontSize, lineHeight, lineYOffset, showTs ? tsWidth : 0f); + long perfT2 = System.nanoTime(); + debugPerfAccumLayoutNs += (perfT1 - perfT0); + debugPerfAccumBuildNs += (perfT2 - perfT2Start); + debugPerfAccumFrames++; + if (debugPerfWindowStartNs == 0L) debugPerfWindowStartNs = perfT2; + long windowNs = perfT2 - debugPerfWindowStartNs; + if (windowNs >= 1_000_000_000L && debugPerfAccumFrames > 0 && chatOpen) { + double layoutMs = (debugPerfAccumLayoutNs / (double) debugPerfAccumFrames) / 1_000_000.0; + double buildMs = (debugPerfAccumBuildNs / (double) debugPerfAccumFrames) / 1_000_000.0; + /*DebugLog.info("[BetterChat][Perf] layout=%.2f ms build=%.2f ms lines=%d msgs=%d scroll=%d/%d vis=%d", + layoutMs, buildMs, allLines.size(), messages.size(), scrollOffsetLines, maxScrollLines, visibleLimit);*/ + debugPerfWindowStartNs = perfT2; + debugPerfAccumLayoutNs = 0L; + debugPerfAccumBuildNs = 0L; + debugPerfAccumFrames = 0; + } + + searchHit = false; + if (!chatOpen) { + BetterChatSearch.deactivate(); + } + updateSearchHotkey(mc, chatOpen); + + MousePos mouse = resolveMouse(mc.mouseHandler.xpos(), mc.mouseHandler.ypos()); + double mouseX = mouse.fx(); + double mouseY = mouse.fy(); + lastMouseFx = mouseX; + lastMouseFy = mouseY; + lastMouseValid = true; + HoverTip hoverTip = null; + hoverEntityUuid = null; + + TextRenderer tsRenderer = showTs ? getInterRegular() : null; + float tsFontSize = Math.max(9f, fontSize * 0.72f); + float tsTextHeight = 0f; + if (showTs && tsRenderer != null) { + float tsScale = scaleForSize(tsFontSize); + tsRenderer.begin(tsScale, false, false); + tsTextHeight = (float) tsRenderer.getHeight(false); + tsRenderer.end(); + } + + boolean chatClip = false; + MessageClipBounds clipBounds = frame.messageClipBounds(); + if (clipBounds != null) { + if (!frame.bubbles().isEmpty()) { + Renderer2D.requestLiquidGlassBlurBeforeNextShapeClip(); + } + float clipRadius = Math.min(RADIUS, clipBounds.h() * 0.5f); + chatClip = ClipFunction.pushRoundedRect( + clipBounds.x(), clipBounds.y(), clipBounds.w(), clipBounds.h(), + clipRadius, clipRadius, 0.0f, 0.0f + ); + } + drawMessageBubbles(frame.bubbles(), activeChatSurface); + for (FrameLine line : frame.lines()) { + float alpha = activeChatSurface ? 1f : fade(line.message().ageSeconds()); + if (alpha <= 0.01f) continue; + drawSelection(line, alpha, lineHeight); + } + + renderGlyphs(frame.lines(), fontSize, lineHeight, activeChatSurface); + if (showTs && tsRenderer != null) { + hoverTip = renderBubbleTimestamps( + tsRenderer, + frame.bubbles(), + tsFontSize, + tsTextHeight, + tsSeconds, + tsColor, + tsTipDate, + tsTipUnix, + activeChatSurface, + chatOpen, + hoverTip + ); + } + if (chatClip) ClipFunction.pop(); + PickResult hover = frame.pick(mouseX, mouseY); + boolean overSuggestWindow = isInsideSuggestWindow(mouse.fx(), mouse.fy(), mouse.rawX(), mouse.rawY()); + if (overSuggestWindow && lastHoverWasOutsideSuggest) { + /*DebugLog.info("[BetterChat][Suggest] hover enter mx=%.1f my=%.1f raw=(%.1f,%.1f) box=(%.1f,%.1f,%.1f,%.1f)", + mouse.fx(), mouse.fy(), mouse.rawX(), mouse.rawY(), suggestX, suggestY, suggestW, suggestH)*/ + lastHoverWasOutsideSuggest = false; + } else if (!overSuggestWindow && !lastHoverWasOutsideSuggest) { + lastHoverWasOutsideSuggest = true; + } + + if (hover != null && hover.glyph().hover() != null && !overSuggestWindow) { + hoverTip = ChatHoverUtil.fromHover(hover.glyph().hover(), mc); + if (hoverTip != null) hoverEntityUuid = hoverTip.uuid(); + } + + // Fallback heuristic: if no hover info was provided (e.g., from persisted history), try to infer from cache or nickname. + if (selecting) { + hoverTip = null; + hoverEntityUuid = null; + } + + if (!selecting && !overSuggestWindow && hoverTip == null && hover != null) { + String raw = hover.line().message().text().getString(); + String word = wordAt(raw, hover.glyph().charIndex()); + if (!word.isEmpty()) { + boolean looksLikeItem = (word.startsWith("[") && word.contains("]")); + HoverTip cached = ChatHoverUtil.inferFromDisplay(word, looksLikeItem, mc); + if (cached != null) { + hoverTip = cached; + hoverEntityUuid = cached.uuid(); + } + } + if (hoverTip == null) { + List nickCandidates = ChatNameUtil.extractNicks(raw); + String normalized = ChatNameUtil.normalizeNickCandidate(word); + String targetNick = null; + if (!normalized.isEmpty() + && ChatNameUtil.isNickLike(normalized) + && nickCandidates.stream().anyMatch(n -> n.equalsIgnoreCase(normalized))) { + targetNick = normalized; + } else if (nickCandidates.size() == 1) { + targetNick = nickCandidates.getFirst(); + } + if (targetNick != null) { + hoverTip = ChatHoverUtil.inferFromNick(targetNick, mc); + if (hoverTip != null) hoverEntityUuid = hoverTip.uuid(); + } + } + } + + if (overSuggestWindow || contextMenu.open) { + hoverTip = null; + hoverEntityUuid = null; + } + + if (chatOpen) { + renderInput(mc, fontSize, width, baseX, fixedInputY); + float hitBottom = hasInputBox ? inputBoxY + inputBoxH : fixedInputY; + chatHitX = baseX; + chatHitY = frame.y(); + chatHitW = width; + chatHitH = Math.max(boxHeight, hitBottom - chatHitY); + renderSuggestions(); + } else { + chatHitX = chatHitY = chatHitW = chatHitH = 0f; + lastMouseValid = false; + } + + if (chatOpen && maxScrollLines > 0) { + int scrollbarStart = autoFollow + ? Math.max(0, sbTotalLines - visibleLimit) + : Math.max(0, sbTotalLines - visibleLimit - scrollOffsetLines); + renderScrollbar(frame.x(), frame.y(), frame.w(), frame.h(), scrollbarStart, visibleLimit, sbTotalLines, mouseX, mouseY); + } + + if (chatOpen && hoverTip != null) { + captureTooltip(hoverTip, (float) mouseX + CHAT_TOOLTIP_OFFSET_X, (float) mouseY + CHAT_TOOLTIP_OFFSET_Y, ctx); + } + + if (contextMenu.open) { + contextMenu.render((float) mouseX, (float) mouseY, fontSize); + } + } + + private static int countMessageGroups(List lines, int maxLines) { + if (lines == null || lines.isEmpty() || maxLines <= 0) return 0; + int groups = 0; + int lastMessage = Integer.MIN_VALUE; + int limit = Math.min(lines.size(), maxLines); + for (int i = 0; i < limit; i++) { + int messageGroup = lines.get(i).messageGroup(); + if (messageGroup != lastMessage) { + groups++; + lastMessage = messageGroup; + } + } + return groups; + } + + private static float messageStackHeight(int lineCount, int groupCount, float lineHeight) { + if (lineCount <= 0) return 0f; + return lineCount * lineHeight + - LINE_SPACING + + Math.max(0, groupCount - 1) * MESSAGE_GAP + + MESSAGE_PAD_Y * 2f; + } + + private static int expandStartToGroup(List lines, int start) { + if (lines == null || lines.isEmpty()) return 0; + int safe = Mth.clamp(start, 0, lines.size()); + if (safe <= 0 || safe >= lines.size()) return safe; + int group = lines.get(safe).messageGroup(); + while (safe > 0 && lines.get(safe - 1).messageGroup() == group) { + safe--; + } + return safe; + } + + private static int expandEndToGroup(List lines, int end) { + if (lines == null || lines.isEmpty()) return 0; + int safe = Mth.clamp(end, 0, lines.size()); + if (safe <= 0 || safe >= lines.size()) return safe; + int group = lines.get(safe - 1).messageGroup(); + while (safe < lines.size() && lines.get(safe).messageGroup() == group) { + safe++; + } + return safe; + } + + private static void drawMessageBubbles(List bubbles, boolean activeChatSurface) { + if (bubbles == null || bubbles.isEmpty()) return; + for (MessageBubble bubble : bubbles) { + float alpha = activeChatSurface ? 1f : fade(bubble.message().ageSeconds()); + if (alpha <= 0.01f) continue; + float reveal = newMessageReveal(bubble.message()); + float x = bubble.x() - (1f - reveal) * NEW_MESSAGE_SLIDE_PX; + float y = bubble.y(); + float w = bubble.w(); + float h = bubble.h(); + drawMessageBubbleGlass(x, y, w, h, Math.min(MESSAGE_RADIUS, h * 0.5f), alpha); + } + } + + private static void drawMessageBubbleGlass(float x, float y, float w, float h, float radius, float alpha) { + if (renderer == null || w <= 0f || h <= 0f) return; + float drawAlpha = Mth.clamp(alpha, 0.0f, 1.0f); + if (drawAlpha <= 0.001f) return; + BetterChat settings = BetterChat.get(); + float configuredAlpha = settings != null ? settings.liquidGlassAlphaFactor() : (230f / 255f); + float blurStrength = configuredAlpha * drawAlpha; + float glassScale = MESSAGE_RADIUS <= 0.0f ? 1.0f : radius / MESSAGE_RADIUS; + // Use the same theme tint family as the input field, but keep message material weaker. + // The previous extra accent fill + bright stroke stacked on top of the glass shader and + // made bubbles visibly more saturated than the input. + HudRenderUtil.drawLiquidGlassCorners( + x, y, w, h, + radius, radius, radius, radius, + glassScale, false, + blurStrength, drawAlpha * 0.74f, + theme().accent() + ); + drawRoundedRect(x, y, w, h, radius, + withAlpha(theme().windowBg(), Math.round(0x08 * drawAlpha))); + } + + private static HoverTip renderBubbleTimestamps(TextRenderer tsRenderer, + List bubbles, + float tsFontSize, + float tsTextHeight, + boolean withSeconds, + int tsColor, + boolean tsTipDate, + boolean tsTipUnix, + boolean activeChatSurface, + boolean chatOpen, + HoverTip currentHover) { + if (bubbles == null || bubbles.isEmpty()) return currentHover; + HoverTip hover = currentHover; + float tsScale = scaleForSize(tsFontSize); + float shadowOffset = Math.max(0.75f, tsFontSize * 0.07f); + tsRenderer.begin(tsScale, false, false); + try { + for (MessageBubble bubble : bubbles) { + float alpha = activeChatSurface ? 1f : fade(bubble.message().ageSeconds()); + if (alpha <= 0.01f) continue; + + String ts = formatTimestamp(bubble.message().timestampMs(), withSeconds); + float textW = (float) tsRenderer.getWidth(ts, false); + float reveal = newMessageReveal(bubble.message()); + float tx = bubble.x() + bubble.w() - MESSAGE_PAD_X - textW - (1f - reveal) * NEW_MESSAGE_SLIDE_PX; + float ty = bubble.lastLine().y0() + (bubble.lastLine().y1() - bubble.lastLine().y0() - tsTextHeight) * 0.5f; + int shadowColor = mulAlpha(0xFF000000, alpha * 0.46f); + applyColor(shadowColor); + tsRenderer.render(ts, tx + shadowOffset, ty, TMP_COLOR, false); + tsRenderer.render(ts, tx, ty + shadowOffset, TMP_COLOR, false); + applyColor(mulAlpha(tsColor, alpha * 0.82f)); + tsRenderer.render(ts, tx, ty, TMP_COLOR, false); + + if (hover == null && chatOpen && lastMouseValid) { + float hitPad = 2f; + if (lastMouseFx >= tx - hitPad && lastMouseFx <= tx + textW + hitPad + && lastMouseFy >= ty - hitPad && lastMouseFy <= ty + tsTextHeight + hitPad) { + hover = buildTimestampTooltip(bubble.message().timestampMs(), tsTipDate, tsTipUnix); + } + } + } + } finally { + tsRenderer.end(); + } + return hover; + } + + private static float fade(float ageSeconds) { + BetterChat cfg = BetterChat.get(); + float life = cfg != null ? cfg.fadeSeconds() : 12f; + if (life <= 0f) return 0f; + + // Держим сообщение полностью видимым, затем быстро гасим в конце. + float fadeDur = Math.min(2.0f, Math.max(0.35f, life * 0.18f)); // быстрое затухание (0.35..2.0с) + float holdDur = Math.max(0f, life - fadeDur); + + if (ageSeconds <= holdDur) return 1f; + + float t = (ageSeconds - holdDur) / fadeDur; // 0..1 + t = Mth.clamp(t, 0f, 1f); + + // Быстрое “падение” в конце (кубическая кривая) + float inv = 1f - t; + return inv * inv * inv; + } + + private static void drawSelection(FrameLine line, float alpha, float lineHeight) { + if (!selection.appliesToLine(line.messageIndex())) return; + + int selStart = selection.startForLine(line.messageIndex()); + int selEnd = selection.endForLine(line.messageIndex()); + if (selStart < 0 && selEnd < 0) return; + + float y0 = line.y0() - 1.5f; + float h = lineHeight + 3f; + float startX = Float.NaN; + float endX = Float.NaN; + for (GlyphBox g : line.glyphs()) { + if (g.charEndExclusive() <= selStart || g.charIndex() > selEnd) continue; + if (Float.isNaN(startX)) startX = g.x0(); + endX = g.x1(); + } + if (Float.isNaN(startX) || Float.isNaN(endX)) return; + float w = Math.max(1f, endX - startX); + drawRoundedRect(startX, y0, w, h, 1.8f, mulAlpha(theme().accent(), alpha * 0.55f)); + } + + private static void renderGlyphs(List lines, float fontSize, float lineHeight, boolean activeChatSurface) { + if (lines == null || lines.isEmpty()) return; + float scale = scaleForSize(fontSize); + TextRenderer current = null; + float currentHeight = 0f; + float shadowOffset = Math.max(1f, fontSize * (activeChatSurface ? 0.06f : 0.08f)); + float shadowAlpha = activeChatSurface ? 0.48f : 0.68f; + for (FrameLine line : lines) { + float alpha = activeChatSurface ? 1f : fade(line.message().ageSeconds()); + if (alpha <= 0.01f) continue; + float baseY = line.y0(); + float reveal = newMessageReveal(line.message()); + float xOffset = -(1f - reveal) * NEW_MESSAGE_SLIDE_PX; + boolean commandLine = CommandOutput.isLoVisualMessage(line.message().text()); + // New-message animation is translation-only. Do not create a nested rectangular + // clip here: every arrival used to push/pop GPU scissor state for ~340 ms, forcing + // batch flushes and briefly invalidating lower HUD phases. + List glyphs = line.glyphs(); + for (int gi = 0; gi < glyphs.size(); gi++) { + GlyphBox g = glyphs.get(gi); + if (commandLine && g.charIndex() < CommandOutput.PREFIX.length() && !TextGlyphFallback.isSvgFontKey(g.font())) { + if (current != null) { + current.end(); + current = null; + } + int runEnd = gi + 1; + while (runEnd < glyphs.size()) { + GlyphBox next = glyphs.get(runEnd); + if (next.charIndex() >= CommandOutput.PREFIX.length()) break; + if (TextGlyphFallback.isSvgFontKey(next.font())) break; + if (!java.util.Objects.equals(next.font(), g.font())) break; + runEnd++; + } + renderCommandPrefixRun(glyphs, gi, runEnd, baseY, lineHeight, xOffset, scale, alpha, shadowOffset, shadowAlpha, line.message().text()); + gi = runEnd - 1; + continue; + } + if (TextGlyphFallback.isSvgFontKey(g.font())) { + if (current != null) { + current.end(); + current = null; + } + String svgName = TextGlyphFallback.svgNameFromFontKey(g.font()); + float gx = g.x0() + xOffset; + float iconSize = Math.max(1.0f, g.x1() - g.x0()); + float iconY = baseY + (lineHeight - iconSize) * 0.5f; + if (svgName != null) { + renderer.svg(svgName, gx, iconY, iconSize, iconSize, SvgRenderOptions.fromFile().withAlpha(alpha)); + } + continue; + } + TextRenderer tr = fontRenderer(g.font()); + if (tr != current) { + if (current != null) { + current.end(); + } + current = tr; + current.begin(scale, false, false); + currentHeight = (float) current.getHeight(false); + } + float textY = baseY + (lineHeight - currentHeight) * 0.5f; + float gx = g.x0() + xOffset; + int shadowColor = mulAlpha(0xFF000000, alpha * shadowAlpha); + applyColor(shadowColor); + current.render(g.text(), gx + shadowOffset, textY, TMP_COLOR, false); + current.render(g.text(), gx - shadowOffset, textY, TMP_COLOR, false); + current.render(g.text(), gx, textY + shadowOffset, TMP_COLOR, false); + current.render(g.text(), gx, textY - shadowOffset, TMP_COLOR, false); + applyColor(mulAlpha(g.color(), alpha)); + current.render(g.text(), gx, textY, TMP_COLOR, false); + } + } + if (current != null) { + current.end(); + } + } + + private static void renderCommandPrefixRun(List glyphs, + int from, + int to, + float baseY, + float lineHeight, + float xOffset, + float scale, + float alpha, + float shadowOffset, + float shadowAlpha, + Component source) { + if (from < 0 || to <= from || to > glyphs.size()) return; + GlyphBox first = glyphs.get(from); + GlyphBox last = glyphs.get(to - 1); + TextRenderer tr = fontRenderer(first.font()); + if (tr == null) return; + + StringBuilder text = new StringBuilder(); + for (int i = from; i < to; i++) { + text.append(glyphs.get(i).text()); + } + if (text.isEmpty()) return; + + float runX = first.x0() + xOffset; + float runW = Math.max(1.0f, last.x1() - first.x0()); + CommandOutput.Tone tone = CommandOutput.toneOf(source); + int start = CommandOutput.prefixStartColor(tone); + int end = CommandOutput.prefixEndColor(tone); + int bottomMix = CommandOutput.mixRgb(end, 0xFFFFFFFF, 0.10f); + + tr.begin(scale, false, false); + try { + float textY = baseY + (lineHeight - (float) tr.getHeight(false)) * 0.5f; + int shadowColor = mulAlpha(0xFF000000, alpha * shadowAlpha); + applyColor(shadowColor); + tr.render(text.toString(), runX + shadowOffset, textY, TMP_COLOR, false); + tr.render(text.toString(), runX - shadowOffset, textY, TMP_COLOR, false); + tr.render(text.toString(), runX, textY + shadowOffset, TMP_COLOR, false); + tr.render(text.toString(), runX, textY - shadowOffset, TMP_COLOR, false); + + tr.renderQuadGradient(text.toString(), runX, textY, (idx, cp, x0, y0, x1, y1, out) -> { + float t0 = (float) ((x0 - runX) / runW); + float t1 = (float) ((x1 - runX) / runW); + int leftTop = mulAlpha(CommandOutput.mixRgb(start, end, t0), alpha); + int rightTop = mulAlpha(CommandOutput.mixRgb(start, end, t1), alpha); + int leftBottom = mulAlpha(CommandOutput.mixRgb(start, bottomMix, t0), alpha); + int rightBottom = mulAlpha(CommandOutput.mixRgb(start, bottomMix, t1), alpha); + out[0] = leftTop; + out[1] = leftBottom; + out[2] = rightBottom; + out[3] = rightTop; + }, false); + } finally { + tr.end(); + } + } + + private static List mergeCommandOutput(List storeLines, + List commandLines, + int needed) { + if (commandLines == null || commandLines.isEmpty()) { + return storeLines; + } + List out = new ArrayList<>(storeLines.size() + commandLines.size()); + out.addAll(storeLines); + for (CommandOutput.MessageLine line : commandLines) { + if (line == null || line.text() == null) continue; + out.add(COMMAND_LINE_CACHE.computeIfAbsent(line, key -> new ChatLine(key.text(), key.timestampMs()))); + } + out.sort(java.util.Comparator.comparingLong(ChatLine::timestampMs)); + trimCommandLineCache(commandLines); + if (needed > 0 && out.size() > needed) { + return new ArrayList<>(out.subList(out.size() - needed, out.size())); + } + return out; + } + + private static void trimCommandLineCache(List liveLines) { + if (COMMAND_LINE_CACHE.size() <= 160) return; + java.util.IdentityHashMap live = new java.util.IdentityHashMap<>(); + for (CommandOutput.MessageLine line : liveLines) { + live.put(line, Boolean.TRUE); + } + COMMAND_LINE_CACHE.keySet().removeIf(line -> !live.containsKey(line)); + } + + private static float newMessageReveal(ChatLine message) { + if (message == null) return 1f; + float age = message.ageSeconds(); + if (age <= 0f) return 0f; + if (age >= NEW_MESSAGE_REVEAL_SECONDS) return 1f; + float t = Mth.clamp(age / NEW_MESSAGE_REVEAL_SECONDS, 0f, 1f); + float inv = 1f - t; + return 1f - inv * inv * inv; + } + + private static void captureTooltip(HoverTip tip, float x, float y, GuiGraphicsExtractor ctx) { + if (tip == null || ctx == null) return; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return; + float scale = (float) mc.getWindow().getGuiScale(); + int mx = Math.round(x / scale); + int my = Math.round(y / scale); + List lines = buildOrderedTooltipLines(tip); + if (lines.isEmpty()) return; + + BetterTooltips.setTooltipScaleOverride(0.8f); + if (tip.item() != null && !tip.item().isEmpty()) { + BetterTooltips.captureItemTooltipOrdered(lines, DefaultTooltipPositioner.INSTANCE, mx, my, tip.item(), ctx); + } else { + BetterTooltips.captureTooltipOrdered(lines, DefaultTooltipPositioner.INSTANCE, mx, my); + } + tooltipCaptured = true; + } + + public static void renderCapturedTooltip(GuiGraphicsExtractor ctx) { + if (!tooltipCaptured) return; + BetterTooltips.renderTooltipWithContext(ctx); + BetterTooltips.resetTooltipScaleOverride(); + tooltipCaptured = false; + } + + private static List buildOrderedTooltipLines(HoverTip tip) { + if (tip == null || tip.lines() == null || tip.lines().isEmpty()) return Collections.emptyList(); + List out = new ArrayList<>(tip.lines().size()); + for (ChatHoverUtil.ColoredLine line : tip.lines()) { + int argb = line.color() != 0 ? line.color() : theme().textPrimary(); + int rgb = argb & 0x00FFFFFF; + Component text = Component.literal(line.text()).setStyle(Style.EMPTY.withColor(net.minecraft.network.chat.TextColor.fromRgb(rgb))); + out.add(text.getVisualOrderText()); + } + return out; + } + + private static LayoutResult layoutLinesTailCached(List messages, int[] messageGroups, float fontSize, float maxWidth, int maxLines) { + if (messages.isEmpty()) return new LayoutResult(Collections.emptyList(), 0); + List lines = new ArrayList<>(); + int seenMessages = 0; + for (int mi = messages.size() - 1; mi >= 0; mi--) { + ChatLine msg = messages.get(mi); + int messageGroup = messageGroups != null && mi >= 0 && mi < messageGroups.length ? messageGroups[mi] : mi; + List cached = cachedMessageLines(msg, fontSize, maxWidth); + for (int j = cached.size() - 1; j >= 0; j--) { + CachedLine cl = cached.get(j); + lines.addFirst(new VisualLine(msg, mi, messageGroup, cl.startChar(), cl.endChar(), cl.glyphs())); + } + seenMessages++; + if (lines.size() >= maxLines) break; + } + return new LayoutResult(lines, seenMessages); + } + + private static List cachedMessageLines(ChatLine msg, float fontSize, float maxWidth) { + CachedMessageLayout cached = MESSAGE_LAYOUT_CACHE.get(msg); + if (cached != null && Math.abs(cached.fontSize() - fontSize) < 0.01f && Math.abs(cached.maxWidth() - maxWidth) < 0.5f) { + return cached.lines(); + } + + List segments = SEGMENT_CACHE.computeIfAbsent(msg, m -> flatten(m.text())); + List rebuilt = new ArrayList<>(); + TextRenderer activeRenderer = null; + String activeFont = null; + float scale = scaleForSize(fontSize); + + int charIndex = 0; + float lineX = 0f; + List glyphs = new ArrayList<>(); + int lineStartIndex = 0; + int breakGlyphIdx = -1; + int breakCharIdx = -1; + + for (Segment seg : segments) { + Style style = seg.style(); + String font = fontForStyle(style); + int color = style.getColor() != null ? (0xFF << 24) | style.getColor().getValue() : theme().textPrimary(); + HoverEvent hover = style.getHoverEvent(); + + if (hover instanceof HoverEvent.ShowItem(net.minecraft.world.item.ItemStackTemplate itemTemplate)) { + net.minecraft.world.item.ItemStack item = itemTemplate.create(); + net.minecraft.world.item.ItemStack stack = item.copy(); + + BetterChatHoverCache cache = BetterChatStoreManager.getActiveCache(); + if (cache != null) { + String keyId = BetterChatStoreManager.hoverItemKey(stack); + String itemId = net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + net.minecraft.world.item.ItemStack cachedStack = cache.getItemByKeyOrId(keyId, itemId); + if (!cachedStack.isEmpty()) stack = cachedStack; + } + + if (IllegalItemUtil.isIllegal(stack)) { + color = IllegalItemUtil.illegalColor(); + } else if (TopEnchantUtil.hasTopEnchant(stack)) { + color = TopEnchantUtil.topColor(); + } + } + + if (color == theme().textPrimary()) { + if (!(hover instanceof HoverEvent.ShowItem)) { + BetterChatHoverCache cache = BetterChatStoreManager.getActiveCache(); + if (cache != null) { + String cleaned = seg.text().replaceAll("[\\[\\]<>‚]", "").trim(); + if (!cleaned.isEmpty()) { + cache.findItemByDisplayNameWithMeta(cleaned); + } + } + } + } + + String txt = seg.text(); + + for (int c = 0; c < txt.length(); ) { + int cp = txt.codePointAt(c); + int cpLen = Character.charCount(cp); + + if (cp == '\n') { + rebuilt.add(new CachedLine(lineStartIndex, Math.max(lineStartIndex, charIndex - 1), glyphs)); + glyphs = new ArrayList<>(); + lineX = 0f; + lineStartIndex = charIndex; + breakGlyphIdx = -1; + breakCharIdx = -1; + charIndex += 1; + c += 1; + continue; + } + + String glyphText = new String(Character.toChars(cp)); + + String resolvedFont = fontForGlyph(font, cp); + boolean svgGlyph = TextGlyphFallback.isSvgFontKey(resolvedFont); + TextRenderer tr = svgGlyph ? null : fontRenderer(resolvedFont); + + if (svgGlyph) { + if (activeRenderer != null) { + activeRenderer.end(); + activeRenderer = null; + } + activeFont = resolvedFont; + } else if (tr != activeRenderer) { + if (activeRenderer != null) { + activeRenderer.end(); + } + activeRenderer = tr; + activeFont = resolvedFont; + if (activeRenderer != null) { + activeRenderer.begin(scale, true, false); + } + } + float cw = svgGlyph + ? svgGlyphSize(fontRenderer(font), fontSize, false) + : activeRenderer != null ? (float) activeRenderer.getWidth(glyphText, false) : 0f; + int glyphCharIndex = charIndex; + + if (lineX + cw > maxWidth && lineX > 0f) { + if (breakGlyphIdx >= 0) { + List before = new ArrayList<>(glyphs.subList(0, breakGlyphIdx)); + int endChar = Math.max(lineStartIndex, breakCharIdx - 1); + rebuilt.add(new CachedLine(lineStartIndex, endChar, before)); + + List remaining = glyphs.subList(breakGlyphIdx + 1, glyphs.size()); + glyphs = new ArrayList<>(remaining.size()); + float nx = 0f; + for (Glyph g : remaining) { + float gw = g.x1() - g.x0(); + glyphs.add(new Glyph( + nx, + nx + gw, + g.charIndex(), + g.charEndExclusive(), + g.color(), + g.hover(), + g.font(), + g.text(), + g.style() + )); + nx += gw; + } + lineX = nx; + lineStartIndex = breakCharIdx + 1; + breakGlyphIdx = -1; + breakCharIdx = -1; + } else { + rebuilt.add(new CachedLine(lineStartIndex, Math.max(lineStartIndex, charIndex - 1), glyphs)); + glyphs = new ArrayList<>(); + lineX = 0f; + lineStartIndex = charIndex; + } + } + + glyphs.add(new Glyph( + lineX, + lineX + cw, + glyphCharIndex, + glyphCharIndex + cpLen, + color, + hover, + activeFont != null ? activeFont : resolvedFont, + glyphText, + style + )); + lineX += cw; + + charIndex += cpLen; + + if (cp == ' ') { + breakGlyphIdx = glyphs.size() - 1; + breakCharIdx = glyphCharIndex; + } + + c += cpLen; + } + } + + if (!glyphs.isEmpty()) { + rebuilt.add(new CachedLine(lineStartIndex, charIndex - 1, glyphs)); + } else if (charIndex == 0) { + rebuilt.add(new CachedLine(0, 0, List.of(new Glyph( + 0f, + 2f, + 0, + 1, + theme().textPrimary(), + null, + "iosevka_medium", + " ", + Style.EMPTY + )))); + } + + if (activeRenderer != null) { + activeRenderer.end(); + } + MESSAGE_LAYOUT_CACHE.put(msg, new CachedMessageLayout(fontSize, maxWidth, rebuilt)); + return rebuilt; + } + + private static List flatten(Component text) { + List segments = new ArrayList<>(); + text.visit((style, string) -> { + if (string != null && !string.isEmpty()) { + segments.add(new Segment(string, style == null ? Style.EMPTY : style)); + } + return Optional.empty(); + }, Style.EMPTY); + return segments; + } + + private static void updateSearchHotkey(Minecraft mc, boolean chatOpen) { + if (!chatOpen || mc == null || mc.getWindow() == null) { + searchHotkeyDown = false; + return; + } + + long handle = mc.getWindow().handle(); + + boolean ctrl = + glfwGetKey(handle, GLFW.GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS || + glfwGetKey(handle, GLFW.GLFW_KEY_RIGHT_CONTROL) == GLFW_PRESS; + + boolean f = + glfwGetKey(handle, GLFW.GLFW_KEY_F) == GLFW_PRESS; + + boolean down = ctrl && f; + + if (down && !searchHotkeyDown) { + toggleSearchMode(); + } + + searchHotkeyDown = down; + } + + private static void toggleSearchMode() { + if (BetterChatSearch.isActive() || BetterChatSearch.hasQuery()) { + BetterChatSearch.deactivate(); + } else { + BetterChatSearch.setActive(true); + } + } + + private static void renderInput(Minecraft mc, float fontSize, float boxW, float x, float inputY) { + if (!(ClientScreen.current() instanceof ChatScreen chatScreen)) return; + EditBox field = ((ChatScreenAccessor) chatScreen).getChatField(); + if (field == null) return; + + String fieldText = field.getValue(); + boolean searchMode = BetterChatSearch.isActive(); + String text = searchMode ? BetterChatSearch.getText() : fieldText; + int cursor = searchMode ? text.length() : field.getCursorPosition(); + int selStart = searchMode ? cursor : selectionStart(field, cursor); + int selEnd = searchMode ? cursor : selectionEnd(field, cursor); + + float padX = 12f; + float padY = 7f; + float lineHeight = fontSize + 2f; + float iconGap = 8f; + float searchBoxW = SEARCH_ICON_WIDTH; + float searchBoxH = SEARCH_ICON_HEIGHT; + float counterReserve = (!searchMode && text.length() >= MAX_MESSAGE_CHARS - 20) ? 34f : 0f; + float modeReserve = (searchMode || BetterChatSearch.hasQuery()) ? 74f : 0f; + float rightReserve = counterReserve + modeReserve; + + if (!searchMode && text.length() > MAX_MESSAGE_CHARS) { + text = text.substring(0, MAX_MESSAGE_CHARS); + field.setValue(text); + setCursorSafe(field, text.length()); + } + int remaining = searchMode ? Integer.MAX_VALUE : MAX_MESSAGE_CHARS - text.length(); + + float maxLineWidth = Math.max(32f, boxW - padX * 2f - searchBoxW - iconGap - rightReserve); + List lines = wrapInput(text, fontSize, maxLineWidth); + if (lines.isEmpty()) { + lines = List.of(new InputLine(0, 0, 0f)); + } + float contentHeight = lines.size() * lineHeight; + float h = Math.max(36f, contentHeight + padY * 2f); + inputBoxX = x; + inputBoxY = inputY; + inputBoxW = boxW; + inputBoxH = h; + hasInputBox = true; + + drawGlassPill(x, inputY, boxW, h, Math.min(INPUT_RADIUS, h * 0.5f), 0.92f, false); + + float searchX = x + padX; + float searchY = inputY + (h - searchBoxH) * 0.5f; + renderSearchIcon(searchX, searchY, searchBoxW, searchBoxH, BetterChatSearch.isActive(), BetterChatSearch.hasQuery()); + searchRectX = searchX; + searchRectY = searchY; + searchRectW = searchBoxW; + searchRectH = searchBoxH; + searchHit = true; + + float textX = searchX + searchBoxW + iconGap; + float textClipW = Math.max(20f, x + boxW - padX - textX - rightReserve); + TextRenderer tr = getIosevkaRegular(); + float textScale = scaleForSize(fontSize); + boolean clipped = ScissorFunction.pushRaw(textX, inputY, textClipW, h); + if (tr != null) { + tr.begin(textScale, false, false); + float textH = (float) tr.getHeight(false); + tr.end(); + float textYOffset = (lineHeight - textH) * 0.5f; + float lineY = inputY + padY + textYOffset; + if (text.isEmpty()) { + drawText(tr, searchMode ? "Search chat..." : "Message...", textX, lineY, fontSize, theme().textMuted(), false); + } else { + for (InputLine line : lines) { + String slice = safeSub(text, line.start(), line.end()); + drawText(tr, slice, textX, lineY, fontSize, theme().textPrimary(), true); + lineY += lineHeight; + } + } + } + + int min = Math.min(selStart, selEnd); + int max = Math.max(selStart, selEnd); + if (max > min) { + for (int i = 0; i < lines.size(); i++) { + InputLine line = lines.get(i); + int lineStart = line.start(); + int lineEnd = line.end(); + int selA = Math.max(min, lineStart); + int selB = Math.min(max, lineEnd); + if (selB > selA) { + float selX = textX + textWidth(getIosevkaRegular(), safeSub(text, lineStart, selA), fontSize); + float selW = textWidth(getIosevkaRegular(), safeSub(text, selA, selB), fontSize); + float sy = inputY + padY + i * lineHeight; + drawRoundedRect(selX, sy, selW, lineHeight, 3.5f, withAlpha(theme().accent(), 0x4A)); + } + } + } + + int caretLine = 0; + for (int i = 0; i < lines.size(); i++) { + InputLine line = lines.get(i); + if (cursor >= line.start() && cursor <= line.end()) { + caretLine = i; + break; + } + } + + float caretX = textX + widthTo(text, lines, cursor, fontSize); + float caretY = inputY + padY + caretLine * lineHeight; + boolean blink = (System.currentTimeMillis() / 500L) % 2 == 0; + if (blink) { + drawRoundedRect(caretX, caretY + 1f, 1.35f, Math.max(8f, lineHeight - 2f), 0.8f, withAlpha(theme().textPrimary(), 0xD8)); + } + if (clipped) ScissorFunction.pop(); + + if (BetterChatSearch.isActive() || BetterChatSearch.hasQuery()) { + String q = BetterChatSearch.isActive() ? "Search" : BetterChatSearch.getText(); + float chipFont = Math.max(10f, fontSize * 0.72f); + String chipText = fitText(getInterRegular(), q, chipFont, Math.max(36f, boxW * 0.28f)); + float chipW = textWidth(getInterRegular(), chipText, chipFont) + 14f; + float chipH = Math.min(22f, h - 8f); + float chipX = x + boxW - padX - chipW - counterReserve; + float chipY = inputY + (h - chipH) * 0.5f; + drawRoundedRect(chipX, chipY, chipW, chipH, chipH * 0.5f, withAlpha(theme().accentSoft(), BetterChatSearch.isActive() ? 0x66 : 0x40)); + drawText(getInterRegular(), chipText, chipX + 7f, chipY + (chipH - textHeight(getInterRegular(), chipFont)) * 0.5f, chipFont, + BetterChatSearch.hasQuery() ? theme().textPrimary() : theme().textMuted(), false); + } + + if (!searchMode && remaining <= 20) { + String remStr = String.valueOf(Math.max(0, remaining)); + float remSize = fontSize * 0.74f; + int remColor = remaining <= 5 ? withAlpha(theme().accent(), 0xFF) : withAlpha(theme().textMuted(), 0xCC); + drawText(getIosevkaRegular(), remStr, searchX + 2.0f, + inputY + h + 3.0f, + remSize, remColor, false); + } + } + + private static List wrapInput(String text, float fontSize, float maxWidth) { + List lines = new ArrayList<>(); + int start = 0; + float width = 0f; + + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + int cpLen = Character.charCount(cp); + + String glyphText = new String(Character.toChars(cp)); + float cw = textWidth(getIosevkaRegular(), glyphText, fontSize); + + if (width + cw > maxWidth && width > 0f) { + lines.add(new InputLine(start, i, width)); + start = i; + width = 0f; + } + + width += cw; + i += cpLen; + } + + lines.add(new InputLine(start, text.length(), width)); + return lines; + } + + private static float widthTo(String text, List lines, int cursorIndex, float fontSize) { + if (lines.isEmpty()) return 0f; + for (InputLine line : lines) { + if (cursorIndex >= line.start() && cursorIndex <= line.end()) { + return textWidth(getIosevkaRegular(), safeSub(text, line.start(), cursorIndex), fontSize); + } + } + InputLine last = lines.getLast(); + return textWidth(getIosevkaRegular(), safeSub(text, last.start(), last.end()), fontSize); + } + + public static boolean onScroll(double delta) { + Minecraft mc = Minecraft.getInstance(); + if (!BetterChat.isActive()) return false; + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + MousePos mouse = resolveMouse(mc.mouseHandler.xpos(), mc.mouseHandler.ypos()); + double mx = mouse.fx(); + double my = mouse.fy(); + double rawX = mouse.rawX(); + double rawY = mouse.rawY(); + boolean insideSuggest = isInsideSuggestWindow(mx, my, rawX, rawY); + if (insideSuggest) { + int step = delta > 0 ? -1 : 1; + int scrollable = Math.max(0, suggestTotal - suggestVisible); + suggestCurrentStart = Mth.clamp(suggestCurrentStart + step, 0, scrollable); + applySuggestionWindowState(suggestCurrentStart, suggestCurrentSelection); + //DebugLog.info("[BetterChat][Suggest] scroll handled start=%d total=%d visible=%d mx=%.1f my=%.1f box=(%.1f,%.1f,%.1f,%.1f)", suggestStart, suggestTotal, suggestVisible, mx, my, suggestX, suggestY, suggestW, suggestH); + return true; + } + int step = delta > 0 ? 3 : -3; + scrollOffsetLines = Mth.clamp(scrollOffsetLines + step, 0, maxScrollLines); + lastScrollInteractionMs = System.currentTimeMillis(); + return false; + } + + public static boolean onMouseButton(double mx, double my, int button, boolean pressed) { + Minecraft mc = Minecraft.getInstance(); + if (!BetterChat.isActive()) return false; + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + + MousePos mouse = resolveMouse(mx, my); + double fx = mouse.fx(); + double fy = mouse.fy(); + boolean leftUp = button == GLFW.GLFW_MOUSE_BUTTON_LEFT && !pressed; + + boolean overSuggestWindow = isInsideSuggestWindow(fx, fy, mouse.rawX(), mouse.rawY()); + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && sbEnabled && isScrollbarHovered(fx, fy)) { + if (pressed) { + draggingScrollbar = true; + sbDragOffset = (fy >= sbThumbY && fy <= sbThumbY + sbThumbH) + ? (float) fy - sbThumbY + : sbThumbH * 0.5f; + scrollToPosition(fy); + } else { + draggingScrollbar = false; + } + return true; + } + if (draggingScrollbar) { + if (leftUp) draggingScrollbar = false; + return true; + } + + float sTop = suggestRowsY; + boolean overSuggestRows = isInsideSuggestRows(fx, fy, mouse.rawX(), mouse.rawY()); + + if (suggestDraggingScrollbar) { + if (leftUp) suggestDraggingScrollbar = false; + return true; + } + + if (suggestHasScrollbar && isInsideSuggestScrollbar(fx, fy)) { + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (pressed) { + suggestDraggingScrollbar = true; + scrollSuggestToPosition(fy); + } + } + return true; + } + + if (overSuggestRows) { + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && pressed) { + int row = (int) ((fy - sTop) / suggestItemH); + row = Mth.clamp(row, 0, Math.max(0, suggestVisible - 1)); + int idx = Mth.clamp(suggestStart + row, 0, Math.max(0, suggestTotal - 1)); + applySuggestion(idx); + //DebugLog.info("[BetterChat][Suggest] click row=%d idx=%d mx=%.1f my=%.1f raw=(%.1f,%.1f) box=(%.1f,%.1f,%.1f,%.1f)", row, idx, fx, fy, mouse.rawX(), mouse.rawY(), suggestX, suggestY, suggestW, suggestH); + return true; + } + // block other clicks while over suggest box + return true; + } + + if (overSuggestWindow) { + return true; + } + + if (searchHit) { + boolean inside = fx >= searchRectX && fx <= searchRectX + searchRectW && fy >= searchRectY && fy <= searchRectY + searchRectH; + if (inside && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (pressed) { + toggleSearchMode(); + } + return true; + } + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && pressed) { + BetterChatSearch.setActive(false); + } + } + + if (contextMenu.open) { + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (pressed && contextMenu.contains(fx, fy)) { + ContextMenu.MenuEntry entry = contextMenu.pick(fy); + if (entry != null) entry.action().run(); + } + contextMenu = ContextMenu.closed(); + return true; + } + } + + PickResult pick = frame.pick(fx, fy); + if (pick == null) { + if (pressed && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + selection.clear(); + selecting = false; + leftPending = false; + } + if (!pressed && button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + selecting = false; + } + contextMenu = ContextMenu.closed(); + return false; + } + + boolean ctrlDown = InputConstants.isKeyDown(mc.getWindow(), GLFW.GLFW_KEY_LEFT_CONTROL) + || InputConstants.isKeyDown(mc.getWindow(), GLFW.GLFW_KEY_RIGHT_CONTROL); + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (pressed) { + // Кандидат на клик/выделение. Выделение начнём только при drag. + leftPending = true; + leftDownFx = fx; + leftDownFy = fy; + leftDownMsgIndex = pick.line().messageIndex(); + leftDownCharIndex = pick.glyph().charIndex(); + leftDownStyle = pick.glyph().style(); + + selecting = false; // не выделяем сразу + contextMenu = ContextMenu.closed(); + return true; // важно: блокируем ванильный suggest по клику + } else { + // отпускание ЛКМ + if (leftPending) { + leftPending = false; + + if (selecting) { + // закончили drag-выделение + selecting = false; + return true; + } + + // одиночный клик (без drag) + boolean handled = handleClickEvent(leftDownStyle, ctrlDown); + if (!handled && ctrlDown) { + handled = tryPrefillTellFromClick(leftDownMsgIndex, leftDownCharIndex); + } + if (!handled) selection.clear(); + return true; // блокируем ванильную обработку кликов по чату + } + } + } else if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + if (pressed) { + openContextMenu(pick, mx, my); + } + } + return false; + } + + public static void onMouseMove(double mx, double my) { + if (!BetterChat.isActive()) return; + MousePos mouse = resolveMouse(mx, my); + double fx = mouse.fx(); + double fy = mouse.fy(); + if (draggingScrollbar) { + scrollToPosition(fy); + return; + } + if (suggestDraggingScrollbar) { + scrollSuggestToPosition(fy); + return; + } + if (isInsideSuggestWindow(fx, fy, mouse.rawX(), mouse.rawY())) return; + + if (leftPending && !selecting) { + double dx = fx - leftDownFx; + double dy = fy - leftDownFy; + if (dx * dx + dy * dy >= DRAG_SELECT_THRESHOLD * DRAG_SELECT_THRESHOLD) { + selection.update(leftDownMsgIndex, leftDownCharIndex); + selecting = true; + } else { + // пока не потащили — не начинаем выделение + return; + } + } + + if (!selecting) return; + PickResult pick = frame.pick(mouse.fx(), mouse.fy()); + if (pick == null) { + pick = nearestFramePick(mouse.fx(), mouse.fy()); + } + if (pick == null) return; + selection.updateCaret(pick.line().messageIndex(), selectionCaretIndex(pick.glyph())); + } + + private static int selectionCaretIndex(GlyphBox glyph) { + if (glyph == null) return 0; + return Math.max(glyph.charIndex(), glyph.charEndExclusive() - 1); + } + + private static PickResult nearestFramePick(double mx, double my) { + if (frame == null || frame.lines() == null || frame.lines().isEmpty()) return null; + + FrameLine bestLine = null; + float bestDist = Float.MAX_VALUE; + for (FrameLine line : frame.lines()) { + float dy; + if (my < line.y0()) { + dy = (float) (line.y0() - my); + } else if (my > line.y1() + LINE_SPACING) { + dy = (float) (my - (line.y1() + LINE_SPACING)); + } else { + dy = 0.0f; + } + + if (dy < bestDist) { + bestDist = dy; + bestLine = line; + } + } + + if (bestLine == null || bestLine.glyphs().isEmpty()) return null; + GlyphBox first = bestLine.glyphs().getFirst(); + GlyphBox last = bestLine.glyphs().getLast(); + GlyphBox glyph = mx <= first.x0() ? first : last; + return new PickResult(bestLine, glyph); + } + + private static void openContextMenu(PickResult pick, double mx, double my) { + List entries = new ArrayList<>(); + ChatLine msg = pick.line().message(); + String full = msg.text().getString(); + int safeLen = full.length(); + BetterChat settings = BetterChat.get(); + long ts = msg.timestampMs(); + boolean tsEnabled = settings != null && settings.timestampEnabled(); + boolean tsUnix = settings != null && settings.timestampToggles().get("hover_unix"); + + if (selection.appliesTo(pick.line().messageIndex()) && selection.hasRange()) { + String selected = selectedText(); + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_selection"), () -> ClipboardUtil.copy(selected))); + } else { + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_message"), () -> ClipboardUtil.copy(full))); + } + + List nickCandidates = ChatNameUtil.extractNicks(full); + String hovered = ChatNameUtil.normalizeNickCandidate(wordAt(full, pick.glyph().charIndex())); + String targetNick = null; + if (!hovered.isEmpty() + && ChatNameUtil.isNickLike(hovered) + && nickCandidates.stream().anyMatch(n -> n.equalsIgnoreCase(hovered))) { + targetNick = hovered; + } else if (nickCandidates.size() == 1) { + targetNick = nickCandidates.getFirst(); + } + if (isOnlinePlayer(targetNick)) { + String nickToTell = targetNick; + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.reply"), () -> prefillTell(nickToTell))); + } + + if (hoverEntityUuid != null && !hoverEntityUuid.isEmpty()) { + String uuidToCopy = hoverEntityUuid; + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_uuid"), () -> ClipboardUtil.copy(uuidToCopy))); + } + + if (tsEnabled) { + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_time"), () -> ClipboardUtil.copy(formatTimestamp(ts, settings.timestampSeconds())))); + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_date"), () -> ClipboardUtil.copy(formatDate(ts)))); + if (tsUnix) { + entries.add(new ContextMenu.MenuEntry(I18n.get("better_chat.context.copy_unix"), () -> ClipboardUtil.copy(String.valueOf(ts / 1000L)))); + } + } + + contextMenu = ContextMenu.open((float) mx, (float) my, entries); + } + + public static void copySelectionToClipboard() { + String selected = selectedText(); + if (selected.isEmpty()) return; + ClipboardUtil.copy(selected); + } + + private static String selectedText() { + if (!selection.hasRange()) return ""; + if (lastMessages == null || lastMessages.isEmpty()) return ""; + + int startMsg = Mth.clamp(selection.startContext(), 0, lastMessages.size() - 1); + int endMsg = Mth.clamp(selection.endContext(), 0, lastMessages.size() - 1); + if (endMsg < startMsg) return ""; + + StringBuilder out = new StringBuilder(); + for (int msgIdx = startMsg; msgIdx <= endMsg; msgIdx++) { + ChatLine line = lastMessages.get(msgIdx); + if (line == null || line.text() == null) continue; + + String full = line.text().getString(); + int len = full.length(); + int start = selection.startForLine(msgIdx); + int end = selection.endForLine(msgIdx); + + int from = start == Integer.MIN_VALUE ? 0 : Mth.clamp(start, 0, len); + int toInclusive = end == Integer.MAX_VALUE ? Math.max(0, len - 1) : Mth.clamp(end, 0, Math.max(0, len - 1)); + int to = Mth.clamp(toInclusive + 1, 0, len); + + if (to > from) { + if (!out.isEmpty()) out.append(' '); + out.append(safeSub(full, from, to)); + } + } + return out.toString(); + } + + private static boolean handleClickEvent(Style style, boolean ctrlDown) { + if (style == null) return false; + ClickEvent evt = style.getClickEvent(); + if (evt == null) return false; + String val = switch (evt) { + case ClickEvent.OpenUrl openUrl -> openUrl.uri().toString(); + case ClickEvent.OpenFile openFile -> openFile.path(); + case ClickEvent.RunCommand run -> run.command(); + case ClickEvent.SuggestCommand sug -> sug.command(); + case ClickEvent.CopyToClipboard copy -> copy.value(); + case ClickEvent.ShowDialog showDialog -> showDialog.dialog().value().toString(); + case ClickEvent.ChangePage changePage -> String.valueOf(changePage.page()); + case ClickEvent.Custom custom -> custom.id().toString(); + default -> null; + }; + if (val == null || val.isEmpty()) return false; + try { + if (evt instanceof ClickEvent.OpenUrl || evt instanceof ClickEvent.OpenFile) { + Minecraft mc = Minecraft.getInstance(); + if (ClientScreen.current() instanceof ChatScreen chat) { + ClientScreen.show(mc, new ConfirmLinkScreen(confirm -> { + if (confirm) Util.getPlatform().openUri(val); + ClientScreen.show(mc, chat); + }, val, false)); + } else { + Util.getPlatform().openUri(val); + } + return true; + } else if (evt instanceof ClickEvent.CopyToClipboard) { + ClipboardUtil.copy(val); + return true; + } else if (evt instanceof ClickEvent.RunCommand && val.startsWith("@")) { + return CommandManager.handle(val); + } else if (evt instanceof ClickEvent.SuggestCommand || evt instanceof ClickEvent.RunCommand) { + // Убираем нежелательный автокомплит: без Ctrl не подставляем команды в поле ввода. + if (!ctrlDown) return false; + prefillChat(val); + return true; + } else { + return false; + } + } catch (Exception e) { + //DebugLog.error("[BetterChat] click action failed", e); + return false; + } + } + + private static boolean tryPrefillTellFromClick(int msgIndex, int charIndex) { + if (msgIndex < 0 || msgIndex >= lastMessages.size()) return false; + String full = lastMessages.get(msgIndex).text().getString(); + List nickCandidates = ChatNameUtil.extractNicks(full); + if (nickCandidates.isEmpty()) return false; + + String hovered = ChatNameUtil.normalizeNickCandidate(wordAt(full, charIndex)); + String targetNick = null; + if (!hovered.isEmpty() + && ChatNameUtil.isNickLike(hovered) + && nickCandidates.stream().anyMatch(n -> n.equalsIgnoreCase(hovered))) { + targetNick = hovered; + } else if (nickCandidates.size() == 1) { + targetNick = nickCandidates.getFirst(); + } + if (!isOnlinePlayer(targetNick)) return false; + prefillTell(targetNick); + return true; + } + + private static void prefillChat(String value) { + if (value == null) return; + Minecraft mc = Minecraft.getInstance(); + if (!(ClientScreen.current() instanceof ChatScreen chatScreen)) return; + EditBox field = ((ChatScreenAccessor) chatScreen).getChatField(); + if (field == null) return; + field.setValue(value); + setCursorSafe(field, value.length()); + } + + private static void prefillTell(String nick) { + Minecraft mc = Minecraft.getInstance(); + if (!(ClientScreen.current() instanceof ChatScreen chatScreen)) return; + if (!isOnlinePlayer(nick)) return; + EditBox field = ((ChatScreenAccessor) chatScreen).getChatField(); + if (field == null) return; + String value = "/tell " + nick + " "; + field.setValue(value); + } + + public static void applyAutoPrefixIfNeeded(EditBox field) { + // Compact BetterChat no longer owns chat-prefix chips; hook kept for compatibility. + } + + private static boolean isOnlinePlayer(String nick) { + if (nick == null || nick.isEmpty()) return false; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.player == null || mc.player.connection == null) return false; + return mc.player.connection.getOnlinePlayers().stream() + .anyMatch(e -> e.getProfile() != null && nick.equalsIgnoreCase(e.getProfile().name())); + } + + private static int withAlpha(int argb, int alpha) { + return (argb & 0x00FFFFFF) | ((alpha & 0xFF) << 24); + } + + private static int mulAlpha(int argb, float a) { + int alpha = (int) (((argb >>> 24) & 0xFF) * a); + return (argb & 0x00FFFFFF) | (Mth.clamp(alpha, 0, 255) << 24); + } + + private static void renderSearchIcon(float x, float y, float w, float h, boolean active, boolean hasQuery) { + boolean hover = lastMouseValid && lastMouseFx >= x && lastMouseFx <= x + w && lastMouseFy >= y && lastMouseFy <= y + h; + float radius = h * 0.5f; + int bg = active + ? withAlpha(theme().accent(), 0x8E) + : (hasQuery ? withAlpha(theme().accentSoft(), 0x8A) : withAlpha(theme().surface(), hover ? 0xC0 : 0xA2)); + int tint = withAlpha(theme().accent(), active ? 0x30 : (hasQuery ? 0x24 : 0x18)); + int strokeA = active ? withAlpha(theme().accent(), 0xC8) : withAlpha(theme().textPrimary(), hover ? 0x38 : 0x24); + int strokeB = withAlpha(theme().accentSoft(), active ? 0x86 : (hasQuery ? 0x66 : 0x40)); + + drawRoundedRect(x, y, w, h, radius, bg); + drawRoundedRect(x + 1f, y + 1f, w - 2f, h - 2f, Math.max(0f, radius - 1f), tint); + drawRoundedRectStrokeGradient(x, y, w, h, radius, 0.7f, strokeA, strokeB); + + TextRenderer icons = Fonts.renderer("Icons", FontInfo.Type.Regular, getInterRegular()); + String icon = "s"; + float iconSize = h * 0.50f; + float iconW = textWidth(icons, icon, iconSize); + float iconH = textHeight(icons, iconSize); + int iconColor = active || hasQuery ? theme().textPrimary() : withAlpha(theme().textPrimary(), hover ? 0xD8 : 0xB8); + drawText(icons, icon, x + (w - iconW) * 0.5f, y + (h - iconH) * 0.5f - h * 0.015f, iconSize, iconColor, false); + } + + private static void drawLiquidHoverPill(float x, float y, float w, float h, float radius, float alpha) { + if (renderer == null || w <= 0f || h <= 0f || alpha <= 0.001f) return; + flushRenderer(); + renderer.liquidGlassRect( + x, y, w, h, + Math.max(1.0f, radius), + 10.5f, + theme().accentSoft(), + Mth.clamp(alpha, 0.0f, 1.0f), + 0.08f, + -18.0f, + 0.82f, + 0.72f, + 0.34f, + 0.095f, + 1.2f + ); + drawRoundedRect(x, y, w, h, radius, withAlpha(theme().surfaceHover(), 0x32)); + drawRoundedRectStrokeGradient(x, y, w, h, radius, 0.55f, + withAlpha(theme().accentSoft(), 0x50), + withAlpha(theme().accent(), 0x34)); + } + + private static void drawScrollbarGlass(float trackX, + float trackY, + float trackW, + float trackH, + float thumbY, + float thumbH, + boolean hot, + int trackAlpha, + int thumbAlpha) { + if (renderer == null || trackW <= 0f || trackH <= 0f || thumbH <= 0f) return; + + SettingsGuiPalette palette = SettingsGuiPalette.current(); + int trackA = withAlpha(palette.moduleScrollTrackA(), trackAlpha); + int trackB = withAlpha(palette.moduleScrollTrackB(), Math.min(255, trackAlpha + 0x18)); + int handleA = withAlpha(palette.moduleScrollHandleA(), thumbAlpha); + int handleB = withAlpha(palette.moduleScrollHandleB(), Math.min(255, thumbAlpha + 0x0C)); + float radius = trackW * 0.5f; + + // Identical structure to ClickGui Modules/Settings: one vertical gradient for the track + // and one for the handle. No rail, highlight, stroke or hover-width decoration. + renderer.roundedRectGradientQuad( + trackX, trackY, trackW, trackH, radius, 1.0f, + trackA, trackB, trackB, trackA + ); + renderer.roundedRectGradientQuad( + trackX, thumbY, trackW, thumbH, radius, 1.0f, + handleA, handleB, handleB, handleA + ); + } + + private static void drawGlassPill(float x, float y, float w, float h, float radius, float alpha, boolean softPanel) { + if (renderer == null || w <= 0f || h <= 0f) return; + + // BetterChat uses liquid glass as a background material only. Text is rendered later + // and is not touched here. Blur strength is separate from the glass veil opacity. + BetterChat settings = BetterChat.get(); + float drawAlpha = Mth.clamp(alpha, 0.0f, 1.0f); + if (drawAlpha <= 0.001f) return; + float configuredAlpha = settings != null ? settings.liquidGlassAlphaFactor() : (230f / 255f); + float blurStrength = configuredAlpha * drawAlpha; + float materialAlpha = Mth.clamp(0.72f + configuredAlpha * 0.28f, 0.0f, 1.0f) * drawAlpha; + float glassRadius = Math.max(2f, radius); + float thickness = softPanel ? 15.5f : 12.5f; + float fresnelPower = softPanel ? -26.0f : -20.0f; + float fresnelAlpha = Mth.clamp(0.72f + configuredAlpha * 0.28f, 0.0f, 1.0f) * drawAlpha; + float baseAlpha = Mth.clamp((softPanel ? 0.66f : 0.68f) + configuredAlpha * (softPanel ? 0.34f : 0.32f), 0.0f, 1.0f); + float fresnelMix = softPanel ? 0.46f : 0.44f; + float distortPx = (softPanel ? 0.172f : 0.145f) * drawAlpha; + + flushRenderer(); + renderer.liquidGlassRect( + x, y, w, h, + glassRadius, + thickness, + theme().accent(), + materialAlpha, + blurStrength, + fresnelPower, + fresnelAlpha, + baseAlpha, + fresnelMix, + distortPx, + softPanel ? 2.65f : 2.25f + ); + + int veilAlpha = Math.round((softPanel ? 0x16 : 0x0C) * drawAlpha); + if (veilAlpha > 0) { + drawRoundedRect(x, y, w, h, glassRadius, withAlpha(theme().windowBg(), veilAlpha)); + } + } + + private static String fitText(TextRenderer font, String text, float size, float maxWidth) { + if (text == null) return ""; + if (textWidth(font, text, size) <= maxWidth) return text; + String suffix = "..."; + float suffixW = textWidth(font, suffix, size); + int end = text.length(); + while (end > 0 && textWidth(font, text.substring(0, end), size) + suffixW > maxWidth) { + end--; + } + return end <= 0 ? suffix : text.substring(0, end) + suffix; + } + + private static TextRenderer getInterRegular() { + return Fonts.renderer("Inter", FontInfo.Type.Regular, TextRenderer.get()); + } + + private static TextRenderer getIosevkaRegular() { + return Fonts.renderer("Iosevka", FontInfo.Type.Regular, TextRenderer.get()); + } + + private static TextRenderer getIosevkaItalic() { + return Fonts.renderer("Iosevka", FontInfo.Type.Italic, getIosevkaRegular()); + } + + private static TextRenderer getIosevkaBold() { + return Fonts.renderer("Iosevka", FontInfo.Type.Bold, getIosevkaRegular()); + } + + private static TextRenderer getIosevkaBoldItalic() { + return Fonts.renderer("Iosevka", FontInfo.Type.BoldItalic, getIosevkaBold()); + } + + private static TextRenderer fontRenderer(String key) { + if (key == null) return getIosevkaRegular(); + return switch (key) { + case "iosevka_bold" -> getIosevkaBold(); + case "iosevka_bold_italic" -> getIosevkaBoldItalic(); + case "iosevka_medium_italic" -> getIosevkaItalic(); + case "vanilla_symbols" -> TextGlyphFallback.vanillaSymbols(getIosevkaRegular()); + case "vanilla" -> VanillaTextRenderer.INSTANCE; + default -> getIosevkaRegular(); + }; + } + + private static String fontForGlyph(String baseFont, int codePoint) { + TextRenderer preferred = fontRenderer(baseFont); + return TextGlyphFallback.fontKeyForGlyph(baseFont, preferred, codePoint, "iosevka_medium"); + } + + private static TextRenderer rendererForGlyph(TextRenderer preferred, int codePoint) { + return TextGlyphFallback.rendererForGlyph(preferred, codePoint); + } + + private static float scaleForSize(float size) { + return size / 18.0f; + } + + private static float textWidth(TextRenderer tr, String text, float size) { + if (tr == null || text == null || text.isEmpty()) return 0f; + float scale = scaleForSize(size); + float svgAdvance = svgGlyphSize(tr, size, false); + TextRenderer current = null; + float width = 0f; + try { + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(tr, cp)) { + if (current != null) { + current.end(); + current = null; + } + width += svgAdvance; + i += Character.charCount(cp); + continue; + } + String glyph = new String(Character.toChars(cp)); + TextRenderer next = rendererForGlyph(tr, cp); + if (next != current) { + if (current != null) current.end(); + current = next; + current.begin(scale, true, false); + } + width += (float) current.getWidth(glyph, false); + i += Character.charCount(cp); + } + return width; + } finally { + if (current != null) current.end(); + } + } + + private static float textHeight(TextRenderer tr, float size) { + if (tr == null) return 0f; + float scale = scaleForSize(size); + tr.begin(scale, true, false); + try { + return (float) tr.getHeight(false); + } finally { + tr.end(); + } + } + + private static void drawText(TextRenderer tr, String text, float x, float y, float size, int argb, boolean shadow) { + if (tr == null || text == null || text.isEmpty()) return; + if (((argb >>> 24) & 0xFF) <= 0) return; + float scale = scaleForSize(size); + float svgSize = svgGlyphSize(tr, size, shadow); + float svgY = y + (textHeight(tr, size) - svgSize) * 0.5f; + applyColor(argb); + TextRenderer current = null; + float cursorX = x; + try { + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(tr, cp)) { + if (current != null) { + current.end(); + current = null; + } + String svgName = TextGlyphFallback.vanillaSvgName(cp); + if (renderer != null && svgName != null) { + renderer.svg(svgName, cursorX, svgY, svgSize, svgSize, + SvgRenderOptions.fromFile().withAlpha(((argb >>> 24) & 0xFF) / 255.0f)); + } + cursorX += svgSize; + i += Character.charCount(cp); + continue; + } + String glyph = new String(Character.toChars(cp)); + TextRenderer next = rendererForGlyph(tr, cp); + if (next != current) { + if (current != null) current.end(); + current = next; + current.begin(scale, false, false); + } + cursorX = (float) current.render(glyph, cursorX, y, TMP_COLOR, shadow); + i += Character.charCount(cp); + } + } finally { + if (current != null) current.end(); + } + } + + private static float svgGlyphSize(TextRenderer tr, float size, boolean shadow) { + return Math.max(1.0f, textHeight(tr, size)) * 0.92f; + } + + private static void drawRoundedRect(float x, float y, float w, float h, float radius, int argb) { + if (renderer == null) return; + if (((argb >>> 24) & 0xFF) <= 0) return; + BetterChat cfg = BetterChat.get(); + boolean useGradient = cfg != null && cfg.gradientEnabled(); + HudRenderUtil.drawHudBackground(renderer, x, y, w, h, radius, 1.1f, argb, useGradient); + } + + private static void drawRoundedRectStrokeGradient(float x, float y, float w, float h, float radius, float thickness, + int startArgb, int endArgb) { + if (renderer == null) return; + if (((startArgb >>> 24) & 0xFF) <= 0 && ((endArgb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectStrokeGradient(x, y, w, h, radius, 1.1f, thickness, startArgb, endArgb, (float) 0.0); + } + + private static void drawRect(float x, float y, float w, float h, int argb) { + if (renderer == null) return; + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.quad(x, y, w, h, argb); + } + + private static void flushRenderer() { + if (renderer == null) return; + if (Renderer2D.isBatching()) { + Renderer2D.flushBatch(); + } else { + renderer.begin(); + } + } + + private static void applyColor(int argb) { + BetterChatRenderer.TMP_COLOR.a = (argb >>> 24) & 0xFF; + BetterChatRenderer.TMP_COLOR.r = (argb >>> 16) & 0xFF; + BetterChatRenderer.TMP_COLOR.g = (argb >>> 8) & 0xFF; + BetterChatRenderer.TMP_COLOR.b = argb & 0xFF; + } + + private static String fontForStyle(Style style) { + boolean bold = style.isBold(); + boolean italic = style.isItalic(); + if (bold && italic) return "iosevka_bold_italic"; + if (bold) return "iosevka_bold"; + if (italic) return "iosevka_medium_italic"; + return "iosevka_medium"; + } + + private static String safeSub(String s, int start, int end) { + if (s == null) return ""; + int len = s.length(); + start = Math.max(0, Math.min(len, start)); + end = Math.max(0, Math.min(len, end)); + if (end < start) { + int t = start; + start = end; + end = t; + } + return s.substring(start, end); + } + + private static int selectionStart(EditBox field, int fallback) { + if (field == null) return fallback; + return Mth.clamp(field.getCursorPosition(), 0, field.getValue().length()); + } + + private static int selectionEnd(EditBox field, int fallback) { + if (field == null) return fallback; + try { + return Mth.clamp(((TextFieldWidgetAccessor) field).combatant$getSelectionEnd(), 0, field.getValue().length()); + } catch (Throwable ignored) { + String selected = field.getHighlighted(); + int cursor = selectionStart(field, fallback); + if (selected == null || selected.isEmpty()) return cursor; + return Mth.clamp(cursor + selected.length(), 0, field.getValue().length()); + } + } + + private static void setCursorSafe(EditBox field, int pos) { + if (field == null) return; + field.moveCursorTo(Mth.clamp(pos, 0, field.getValue().length()), false); + } + + private static String wordAt(String text, int charIndex) { + if (text == null || text.isEmpty()) return ""; + int idx = Mth.clamp(charIndex, 0, Math.max(0, text.length() - 1)); + int start = idx; + while (start > 0 && !Character.isWhitespace(text.charAt(start - 1))) start--; + int end = idx; + while (end < text.length() && !Character.isWhitespace(text.charAt(end))) end++; + if (end <= start) return ""; + return text.substring(start, end).replaceAll("[<>:\\[\\]]", ""); + } + + private static MousePos resolveMouse(double rawX, double rawY) { + // No scaling; keep raw and framebuffer identical. + return new MousePos(rawX, rawY, rawX, rawY); + } + + public static void resetScroll() { + scrollOffsetLines = 0; + smoothScrollOffsetLines = 0f; + draggingScrollbar = false; + sbEnabled = false; + sbDragOffset = 0f; + } + + public static float getLastWidth() { + return lastRenderWidth; + } + + public static float getLastHeight() { + return lastRenderHeight; + } + + public static boolean contains(float mx, float my) { + return isInteractive(mx, my); + } + + public static boolean isInteractive(float mx, float my) { + boolean inChat = chatHitW > 0f && mx >= chatHitX && mx <= chatHitX + chatHitW && my >= chatHitY && my <= chatHitY + chatHitH; + boolean inSearch = searchHit && mx >= searchRectX && mx <= searchRectX + searchRectW && my >= searchRectY && my <= searchRectY + searchRectH; + boolean inInput = hasInputBox && mx >= inputBoxX && mx <= inputBoxX + inputBoxW && my >= inputBoxY && my <= inputBoxY + inputBoxH; + boolean inSuggest = isInsideSuggestWindow(mx, my); + boolean inContext = contextMenu.open && contextMenu.contains(mx, my); + boolean inScrollbar = sbEnabled && isScrollbarHovered(mx, my); + return inChat || inSearch || inInput || inSuggest || inContext || inScrollbar; + } + + public static void onNewMessage() { + if (scrollOffsetLines > 0 && scrollOffsetLines <= 2) { + scrollOffsetLines = 0; + smoothScrollOffsetLines = 0f; + } + } + + private static boolean isInsideSuggestRows(double mx, double my) { + if (!suggestActive) return false; + return mx >= suggestRowsX && mx <= suggestRowsX + suggestRowsW + && my >= suggestRowsY && my <= suggestRowsY + suggestRowsH; + } + + private static boolean isInsideSuggestRows(double mx, double my, double rawX, double rawY) { + return isInsideSuggestRows(mx, my) || isInsideSuggestRows(rawX, rawY); + } + + private static boolean isInsideSuggestWindow(double mx, double my) { + if (!suggestActive) return false; + return mx >= suggestX && mx <= suggestX + suggestW && my >= suggestY && my <= suggestY + suggestH; + } + + private static boolean isInsideSuggestWindow(double mx, double my, double rawX, double rawY) { + return isInsideSuggestWindow(mx, my) || isInsideSuggestWindow(rawX, rawY); + } + + private static boolean isInsideSuggestScrollbar(double mx, double my) { + if (!suggestActive || !suggestHasScrollbar) return false; + float hitPad = 4f; + return mx >= suggestTrackX - hitPad && + mx <= suggestTrackX + suggestTrackW + hitPad && + my >= suggestTrackY - hitPad && + my <= suggestTrackY + suggestTrackH + hitPad; + } + + private static float computeWidth(int screenW, float ratio) { + float clamped = Mth.clamp(ratio, 0.2f, 0.8f); + float w = screenW * clamped; + return Mth.clamp(w, 320f, 560f); + } + + private static BetterChatStore buildPreviewStore() { + if (previewStore == null) { + BetterChatStore store = new BetterChatStore(); + store.add(Component.literal("Better Chat")); + store.add(Component.literal("Drag to position")); + store.add(Component.literal("Messages appear here")); + previewStore = store; + } + return previewStore; + } + + private static void renderScrollbar( + float boxX, + float boxY, + float boxW, + float boxH, + int startLine, + int visibleLines, + int totalLines, + double mouseX, + double mouseY) { + if (visibleLines <= 0 || totalLines <= visibleLines) { + sbEnabled = false; + draggingScrollbar = false; + return; + } + + int scrollable = Math.max(1, totalLines - visibleLines); + boolean hot = draggingScrollbar || isScrollbarHovered(mouseX, mouseY); + boolean recently = System.currentTimeMillis() - lastScrollInteractionMs < 850L; + float trackW = 5.0f; + float trackX = boxX - trackW - 7f; + float trackY = boxY + 10f; + float trackH = Math.max(16f, boxH - 20f); + float thumbH = Math.max(18f, trackH * ((float) visibleLines / (float) totalLines)); + float ratio = (float) startLine / (float) scrollable; + float thumbY = trackY + (trackH - thumbH) * Mth.clamp(ratio, 0f, 1f); + + sbTrackX = trackX; + sbTrackY = trackY; + sbTrackW = trackW; + sbTrackH = trackH; + sbThumbY = thumbY; + sbThumbH = thumbH; + sbEnabled = true; + + if (hot || draggingScrollbar) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + + int trackAlpha = hot ? 0xB8 : (recently ? 0x9C : 0x84); + int thumbAlpha = hot ? 0xFF : (recently ? 0xEC : 0xD4); + drawScrollbarGlass(trackX, trackY, trackW, trackH, thumbY, thumbH, hot, trackAlpha, thumbAlpha); + } + + private static boolean isScrollbarHovered(double mx, double my) { + float hitPad = 5f; + return sbEnabled && + mx >= sbTrackX - hitPad && + mx <= sbTrackX + sbTrackW + hitPad && + my >= sbTrackY - hitPad && + my <= sbTrackY + sbTrackH + hitPad; + } + + private static void scrollToPosition(double my) { + if (!sbEnabled || sbVisibleLines <= 0 || sbTotalLines <= sbVisibleLines) return; + float trackSpan = sbTrackH - sbThumbH; + if (trackSpan <= 0f) return; + float rel = (float) (my - sbTrackY - sbDragOffset); + float t = Mth.clamp(rel / trackSpan, 0f, 1f); + int scrollable = Math.max(0, sbTotalLines - sbVisibleLines); + int targetStart = Math.round(t * scrollable); + scrollOffsetLines = Mth.clamp(sbTotalLines - sbVisibleLines - targetStart, 0, scrollable); + lastScrollInteractionMs = System.currentTimeMillis(); + } + + private static void scrollSuggestToPosition(double my) { + if (!suggestHasScrollbar || suggestVisible <= 0 || suggestTotal <= suggestVisible) return; + float trackSpan = suggestTrackH - suggestThumbH; + if (trackSpan <= 0f) return; + float rel = (float) (my - suggestTrackY - suggestThumbH * 0.5f); + float t = Mth.clamp(rel / trackSpan, 0f, 1f); + int scrollable = Math.max(0, suggestTotal - suggestVisible); + int targetStart = Math.round(t * scrollable); + suggestCurrentStart = Mth.clamp(targetStart, 0, scrollable); + applySuggestionWindowState(suggestCurrentStart, suggestCurrentSelection); + } + + private static void applySuggestionWindowState(int start, int selection) { + if (suggestWindow == null) return; + SuggestionWindowAccessor acc = (SuggestionWindowAccessor) suggestWindow; + acc.setCombatant$inWindowIndex(Math.max(0, start)); + if (selection >= 0) { + acc.setCombatant$selection(selection); + } + } + + private static void applySuggestion(int idx) { + Minecraft mc = Minecraft.getInstance(); + if (!(ClientScreen.current() instanceof ChatScreen chatScreen)) return; + EditBox field = ((ChatScreenAccessor) chatScreen).getChatField(); + if (field == null) return; + + // Update vanilla selection state for completeness + if (suggestWindow != null) { + SuggestionWindowAccessor acc = (SuggestionWindowAccessor) suggestWindow; + acc.setCombatant$selection(idx); + } + + if (idx < 0 || idx >= suggestEntries.size()) return; + com.mojang.brigadier.suggestion.Suggestion suggestion = suggestEntries.get(idx); + String current = field.getValue(); + try { + String applied = suggestion.apply(current); + field.setValue(applied); + setCursorSafe(field, applied.length()); + suggestCurrentSelection = idx; + applySuggestionWindowState(suggestCurrentStart, suggestCurrentSelection); + } catch (Exception e) { + //DebugLog.error("[BetterChat][Suggest] apply failed", e); + } + } + + private static CommandSuggestorBridge.SuggestionSnapshot buildCustomSnapshot(String text, int cursor) { + List suggestions = CommandManager.suggest(text, cursor); + if (suggestions == null || suggestions.isEmpty()) return null; + List texts = new ArrayList<>(suggestions.size()); + for (com.mojang.brigadier.suggestion.Suggestion s : suggestions) { + texts.add(s.getText()); + } + int selection = 0; + int start = 0; + int visible = Math.min(5, Math.max(1, suggestions.size())); + return new CommandSuggestorBridge.SuggestionSnapshot(0, 0, 0, 0, texts, suggestions, selection, start, visible); + } + + @SuppressWarnings("unused") + public static void setSuggestionContext(ChatScreen screen, GuiGraphicsExtractor context, int mouseX, int mouseY) { + storedScreen = screen; + } + + private static char commandPrefix(String text) { + if (text == null) return 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isWhitespace(c)) continue; + if (c == '/' || c == '@') return c; + return 0; + } + return 0; + } + + private static boolean refreshSuggestionsIfNeeded(String text, int cursor, int selStart, int selEnd) { + if (text.equals(lastSuggestInput) + && cursor == lastSuggestCursor + && selStart == lastSuggestSelStart + && selEnd == lastSuggestSelEnd) { + return false; + } + lastSuggestInput = text; + lastSuggestCursor = cursor; + lastSuggestSelStart = selStart; + lastSuggestSelEnd = selEnd; + return true; + } + + private static void resetSuggestionTracking() { + lastSuggestInput = ""; + lastSuggestCursor = -1; + lastSuggestSelStart = -1; + lastSuggestSelEnd = -1; + lastSnapshot = null; + lastSuggestionHash = 0; + suggestWindow = null; + suggestEntries = Collections.emptyList(); + suggestRowsX = suggestRowsY = suggestRowsW = suggestRowsH = 0f; + suggestCurrentStart = -1; + suggestCurrentSelection = -1; + } + + private static void renderSuggestions() { + suggestActive = false; + suggestHasScrollbar = false; + if (storedScreen == null) return; + EditBox field = ((ChatScreenAccessor) storedScreen).getChatField(); + String text = field != null ? field.getValue() : null; + if (field == null || text == null || text.isEmpty()) { + storedScreen = null; + resetSuggestionTracking(); + return; + } + + int cursor = field.getCursorPosition(); + int selStart = selectionStart(field, cursor); + int selEnd = selectionEnd(field, cursor); + + char prefix = commandPrefix(text); + if (prefix == 0) { + storedScreen = null; + resetSuggestionTracking(); + return; + } + + boolean inputChanged = refreshSuggestionsIfNeeded(text, cursor, selStart, selEnd); + boolean windowChanged = false; + if (prefix == '/') { + windowChanged = CommandSuggestorBridge.peekWindow(storedScreen) != CommandSuggestorBridge.lastWindow(); + } + if (inputChanged || windowChanged || lastSnapshot == null) { + if (prefix == '@') { + lastSnapshot = buildCustomSnapshot(text, cursor); + } else { + lastSnapshot = CommandSuggestorBridge.snapshot(storedScreen); + } + } + CommandSuggestorBridge.SuggestionSnapshot snap = lastSnapshot; + if (snap == null) { + storedScreen = null; + resetSuggestionTracking(); + return; + } + if (prefix == '@') { + suggestWindow = null; + } + + // Debug logging once per snapshot content to confirm suggestions arrive + int hash = snap.texts().hashCode() ^ snap.selection() ^ snap.startIndex(); + boolean snapshotChanged = hash != lastSuggestionHash; + if (snapshotChanged) { + lastSuggestionHash = hash; + /*DebugLog.info( + "[BetterChat][Suggest] snapshot texts=%d sel=%d start=%d vis=%d", + snap.texts().size(), + snap.selection(), + snap.startIndex(), + snap.visibleCount() + );*/ + } + suggestEntries = snap.suggestions(); + + int total = snap.texts().size(); + int visible = Math.min(5, Math.max(1, total - snap.startIndex())); + int scrollable = Math.max(0, total - visible); + + if (snapshotChanged) { + suggestCurrentStart = Mth.clamp(snap.startIndex(), 0, scrollable); + suggestCurrentSelection = Mth.clamp(snap.selection(), 0, Math.max(0, total - 1)); + } + if (suggestCurrentStart < 0) { + suggestCurrentStart = Mth.clamp(snap.startIndex(), 0, scrollable); + } else { + suggestCurrentStart = Mth.clamp(suggestCurrentStart, 0, scrollable); + } + if (suggestCurrentSelection < 0) { + suggestCurrentSelection = Mth.clamp(snap.selection(), 0, Math.max(0, total - 1)); + } else { + suggestCurrentSelection = Mth.clamp(suggestCurrentSelection, 0, Math.max(0, total - 1)); + } + applySuggestionWindowState(suggestCurrentStart, suggestCurrentSelection); + + float baseFont = Math.max(10f, lastFontSizeForUi * 0.95f); + float itemH = baseFont + 6f; + + float screenW = Minecraft.getInstance().getWindow().getWidth(); + float h = itemH * visible + 6f; + float x; + float w; + float y; + + if (hasInputBox && inputBoxW > 0f) { + x = inputBoxX; + float needed = textWidthRange(snap.texts(), snap.startIndex(), visible, baseFont) + 18f; + w = Math.max(140f, Math.min(inputBoxW, Math.max(needed, 0f))); + y = inputBoxY - h - 4f; + } else { + double scale = Minecraft.getInstance().getWindow().getGuiScale(); + x = Math.max(4f, (float) (snap.x() * scale)); + float snapW = snap.w() > 0 ? (float) (snap.w() * scale) : 200f; + float needed = textWidthRange(snap.texts(), snap.startIndex(), visible, baseFont) + 18f; + w = Math.max(140f, Math.max(needed, snapW)); + y = (float) (snap.y() * scale) - h - 2f; + } + if (y < 4f) y = 4f; + if (x + w > screenW - 4f) x = screenW - w - 4f; + + // avoid zero-size windows + if (w <= 2f || h <= 2f || snap.texts().isEmpty()) { + storedScreen = null; + return; + } + + boolean hasScrollbar = total > visible; + float scrollbarReserve = hasScrollbar ? 13f : 0f; + float maxTextWidth = textWidthRange(snap.texts(), snap.startIndex(), visible, baseFont); + w = Math.max(w, maxTextWidth + 18f + scrollbarReserve); + + drawGlassPill(x, y, w, h, 7f, 0.82f, true); + + float trackW = hasScrollbar ? 4.8f : 0f; + float trackX = x + w - trackW - 5f; + float trackY = y + 3.5f; + float trackH = h - 7f; + float thumbH = hasScrollbar ? Math.max(12f, trackH * ((float) visible / (float) total)) : 0f; + float thumbY = hasScrollbar ? trackY + (trackH - thumbH) * ((float) suggestCurrentStart / (float) (Math.max(1, total - visible))) : trackY; + float rowX = x + 3f; + float rowY = y + 3f; + float rowW = Math.max(0f, (hasScrollbar ? trackX - 3f : x + w - 3f) - rowX); + float rowH = visible * itemH; + + for (int i = 0; i < visible; i++) { + int idx = suggestCurrentStart + i; + if (idx < 0 || idx >= snap.texts().size()) break; + float top = rowY + i * itemH; + boolean hoverRow = lastMouseValid && + lastMouseFx >= rowX && lastMouseFx <= rowX + rowW && + lastMouseFy >= top && lastMouseFy <= top + itemH; + if (hoverRow) { + drawLiquidHoverPill(rowX, top + 0.6f, rowW, itemH - 1.2f, 4.5f, 0.46f); + } + float textH = textHeight(getIosevkaRegular(), baseFont); + float textY = top + (itemH - textH) * 0.5f; + drawText(getIosevkaRegular(), snap.texts().get(idx), rowX + 4f, textY, baseFont, theme().textPrimary(), true); + } + + if (hasScrollbar) { + boolean hotScroll = suggestDraggingScrollbar || (lastMouseValid + && lastMouseFx >= trackX - 4f && lastMouseFx <= trackX + trackW + 4f + && lastMouseFy >= trackY - 4f && lastMouseFy <= trackY + trackH + 4f); + if (hotScroll) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + drawScrollbarGlass(trackX, trackY, trackW, trackH, thumbY, thumbH, hotScroll, hotScroll ? 0xB0 : 0x88, hotScroll ? 0xFF : 0xD8); + } + + suggestActive = true; + suggestX = x; + suggestY = y; + suggestW = w; + suggestH = h; + suggestItemH = itemH; + suggestRowsX = rowX; + suggestRowsY = rowY; + suggestRowsW = rowW; + suggestRowsH = rowH; + suggestStart = suggestCurrentStart; + suggestVisible = visible; + suggestTotal = total; + suggestWindow = CommandSuggestorBridge.lastWindow(); + suggestHasScrollbar = hasScrollbar; + suggestTrackX = trackX; + suggestTrackY = trackY; + suggestTrackW = trackW; + suggestTrackH = trackH; + suggestThumbH = thumbH; + if (!hasScrollbar) suggestDraggingScrollbar = false; + //DebugLog.info("[BetterChat][Suggest] box x=%.1f y=%.1f w=%.1f h=%.1f start=%d vis=%d total=%d", x, y, w, h, suggestStart, suggestVisible, suggestTotal); + storedScreen = null; + } + + private static float textWidthRange(List list, int start, int count, float fontSize) { + float max = 0f; + for (int i = 0; i < count; i++) { + int idx = start + i; + if (idx < 0 || idx >= list.size()) break; + max = Math.max(max, textWidth(getIosevkaRegular(), list.get(idx), fontSize)); + } + return max; + } + + private static String formatTimestamp(long ms, boolean withSeconds) { + LocalDateTime dt = LocalDateTime.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault()); + DateTimeFormatter fmt = DateTimeFormatter.ofPattern(withSeconds ? "HH:mm:ss" : "HH:mm"); + return dt.format(fmt); + } + + private static String formatDate(long ms) { + LocalDateTime dt = LocalDateTime.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault()); + return dt.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); + } + + private static HoverTip buildTimestampTooltip(long ms, boolean showDate, boolean showUnix) { + List lines = new ArrayList<>(); + LocalDateTime dt = LocalDateTime.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault()); + if (showDate) { + lines.add(new ChatHoverUtil.ColoredLine(I18n.get( + "better_chat.hover.date", + dt.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) + ), 0)); + } + if (showUnix) { + lines.add(new ChatHoverUtil.ColoredLine("Unix: " + (ms / 1000L), 0xFF888888)); + } + if (lines.isEmpty()) return null; + return new HoverTip(lines, null, false, net.minecraft.world.item.ItemStack.EMPTY); + } + + private record MousePos(double rawX, double rawY, double fx, double fy) { + } + + private record InputLine(int start, int end, float width) { + } + + private record Segment(String text, Style style) { + } + + private record VisualLine(ChatLine message, int messageIndex, int messageGroup, int startChar, int endChar, List glyphs) { + } + + private record Glyph( + float x0, + float x1, + int charIndex, + int charEndExclusive, + int color, + HoverEvent hover, + String font, + String text, + Style style + ) { + } + + private record GlyphBox( + float x0, + float y0, + float x1, + float y1, + int charIndex, + int charEndExclusive, + int color, + String font, + HoverEvent hover, + String text, + Style style + ) { + } + + private record CachedLine(int startChar, int endChar, List glyphs) { + } + + private record CachedMessageLayout(float fontSize, float maxWidth, List lines) { + } + + private record FrameLine(ChatLine message, int messageIndex, int messageGroup, float y0, float y1, List glyphs) { + } + + private record MessageBubble(ChatLine message, int messageGroup, float x, float y, float w, float h, FrameLine firstLine, FrameLine lastLine) { + } + + private record MessageClipBounds(float x, float y, float w, float h) { + } + + private record FrameLayout(float x, float y, float w, float h, List lines, List bubbles) { + static FrameLayout empty() { + return new FrameLayout(0, 0, 0, 0, Collections.emptyList(), Collections.emptyList()); + } + + static FrameLayout build(List lines, float x, float y, float w, float h, float fontSize, float lineHeight, float yOffset, float timestampReserve) { + List list = new ArrayList<>(lines.size()); + float cursorY = y + BetterChatRenderer.MESSAGE_PAD_Y + yOffset; + int lastMessageGroup = Integer.MIN_VALUE; + for (int i = 0; i < lines.size(); i++) { + VisualLine vl = lines.get(i); + if (i > 0 && vl.messageGroup() != lastMessageGroup) { + cursorY += BetterChatRenderer.MESSAGE_GAP; + } + lastMessageGroup = vl.messageGroup(); + float y0 = cursorY; + float y1 = y0 + fontSize; + List boxes = new ArrayList<>(vl.glyphs().size()); + for (Glyph g : vl.glyphs()) { + boxes.add(new GlyphBox( + x + BetterChatRenderer.PADDING + BetterChatRenderer.MESSAGE_PAD_X + g.x0(), + y0, + x + BetterChatRenderer.PADDING + BetterChatRenderer.MESSAGE_PAD_X + g.x1(), + y1, + g.charIndex(), + g.charEndExclusive(), + g.color(), + g.font(), + g.hover(), + g.text(), + g.style() + )); + } + list.add(new FrameLine(vl.message(), vl.messageIndex(), vl.messageGroup(), y0, y1, boxes)); + cursorY += lineHeight; + } + return new FrameLayout(x, y, w, h, list, buildBubbles(list, x, w, timestampReserve)); + } + + private static List buildBubbles(List lines, float x, float w, float timestampReserve) { + if (lines.isEmpty()) return Collections.emptyList(); + List bubbles = new ArrayList<>(); + int start = 0; + while (start < lines.size()) { + FrameLine first = lines.get(start); + int end = start; + float maxRight = x + BetterChatRenderer.PADDING + BetterChatRenderer.MESSAGE_PAD_X; + while (end + 1 < lines.size() && lines.get(end + 1).messageGroup() == first.messageGroup()) { + end++; + } + for (int i = start; i <= end; i++) { + FrameLine line = lines.get(i); + if (!line.glyphs().isEmpty()) { + maxRight = Math.max(maxRight, line.glyphs().getLast().x1()); + } + } + FrameLine last = lines.get(end); + float bubbleX = x + BetterChatRenderer.PADDING; + float bubbleY = first.y0() - BetterChatRenderer.MESSAGE_PAD_Y; + float maxBubbleW = Math.max(32f, w - BetterChatRenderer.PADDING * 2f); + float desiredW = maxRight - bubbleX + BetterChatRenderer.MESSAGE_PAD_X + Math.max(0f, timestampReserve); + float bubbleW = Mth.clamp(desiredW, 76f, maxBubbleW); + float bubbleH = last.y1() - first.y0() + BetterChatRenderer.MESSAGE_PAD_Y * 2f; + bubbles.add(new MessageBubble(last.message(), first.messageGroup(), bubbleX, bubbleY, bubbleW, bubbleH, first, last)); + start = end + 1; + } + return bubbles; + } + + MessageClipBounds messageClipBounds() { + if (lines.isEmpty() || h <= 0f) return null; + /* + * Match the mask horizontally to the message bubbles, not to the outer draggable HUD + * bounds. Bubbles begin at x + PADDING; using the outer x left almost the whole top-left + * radius in empty padding, so the remaining visible cut looked rectangular even though + * the stencil polygon itself was rounded. + * + * The Y/H stay viewport-fixed: overscan/group expansion may move bubbles through this + * shape, but must never expand the clipping shape with them. + */ + float clipX = x + BetterChatRenderer.PADDING; + float clipW = Math.max(1f, w - BetterChatRenderer.PADDING * 2f); + return new MessageClipBounds(clipX, y, clipW, h); + } + + boolean contains(double mx, double my) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + PickResult pick(double mx, double my) { + if (!contains(mx, my)) return null; + for (FrameLine line : lines) { + if (my < line.y0() || my > line.y1() + LINE_SPACING) continue; + if (line.glyphs().isEmpty()) continue; + float minX = line.glyphs().getFirst().x0(); + float maxX = line.glyphs().getLast().x1(); + float margin = 2f; + if (mx < minX - margin || mx > maxX + margin) continue; + GlyphBox closest = null; + for (GlyphBox g : line.glyphs()) { + if (mx >= g.x0() && mx <= g.x1()) { + return new PickResult(line, g); + } + if (closest == null || Math.abs(mx - g.x1()) < Math.abs(mx - closest.x1())) { + closest = g; + } + } + if (closest != null) { + return new PickResult(line, closest); + } + } + return null; + } + } + + private record PickResult(FrameLine line, GlyphBox glyph) { + } + + private record ContextMenu(boolean open, float x, float y, List entries) { + private static final float MENU_PAD_X = 8f; + private static final float MENU_PAD_Y = 7f; + private static final float MENU_RADIUS = 9f; + + static ContextMenu closed() { + return new ContextMenu(false, 0, 0, Collections.emptyList()); + } + + static ContextMenu open(float x, float y, List entries) { + return new ContextMenu(true, x, y, entries); + } + + private static float itemHeight(float fontSize) { + return Math.max(20f, fontSize + 7f); + } + + boolean contains(double mx, double my) { + if (!open) return false; + float fontSize = lastFontSizeForUi * 0.92f; + return mx >= x && mx <= x + width(fontSize) && my >= y && my <= y + height(fontSize); + } + + MenuEntry pick(double my) { + if (!open) return null; + float fontSize = lastFontSizeForUi * 0.92f; + float itemH = itemHeight(fontSize); + float cy = y + MENU_PAD_Y; + for (int i = 0; i < entries.size(); i++) { + float top = cy + i * itemH; + float bottom = top + itemH; + if (my >= top && my <= bottom) { + return entries.get(i); + } + } + return null; + } + + void render(float mouseX, float mouseY, float fontSize) { + if (!open || entries.isEmpty()) return; + float fs = Math.max(12f, fontSize * 0.92f); + float itemH = itemHeight(fs); + float w = width(fs); + float h = height(fs); + + drawGlassPill(x, y, w, h, MENU_RADIUS, 0.94f, true); + + float accentX = x + 5f; + drawRoundedRect(accentX, y + MENU_PAD_Y + 2f, 2f, h - MENU_PAD_Y * 2f - 4f, 1f, withAlpha(theme().accent(), 0x72)); + + float cy = y + MENU_PAD_Y; + for (MenuEntry entry : entries) { + boolean hover = mouseX >= x && mouseX <= x + w && mouseY >= cy && mouseY <= cy + itemH; + if (hover) { + drawRoundedRect(x + 8f, cy + 1f, w - 16f, itemH - 2f, itemH * 0.5f, withAlpha(theme().surfaceHover(), 0xAA)); + drawRoundedRectStrokeGradient( + x + 8f, cy + 1f, w - 16f, itemH - 2f, itemH * 0.5f, + 0.55f, + withAlpha(theme().accent(), 0x66), + withAlpha(theme().accentSoft(), 0x38) + ); + } + + float dot = 3.2f; + int dotColor = hover ? withAlpha(theme().accent(), 0xE0) : withAlpha(theme().textMuted(), 0x70); + drawRoundedRect(x + 14f, cy + (itemH - dot) * 0.5f, dot, dot, dot * 0.5f, dotColor); + + float textY = cy + (itemH - textHeight(getIosevkaRegular(), fs)) * 0.5f; + int textColor = hover ? theme().textPrimary() : withAlpha(theme().textPrimary(), 0xE4); + drawText(getIosevkaRegular(), entry.label(), x + 24f, textY, fs, textColor, true); + cy += itemH; + } + } + + private float width(float fontSize) { + float maxText = 0f; + for (MenuEntry e : entries) { + maxText = Math.max(maxText, textWidth(getIosevkaRegular(), e.label(), fontSize)); + } + return Math.max(168f, maxText + MENU_PAD_X * 2f + 34f); + } + + private float height(float fontSize) { + return MENU_PAD_Y * 2f + entries.size() * itemHeight(fontSize); + } + + record MenuEntry(String label, Runnable action) { + } + } + + public record Layout(float x, float y) { + } + + private record LayoutResult(List lines, int messagesSeen) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatSearch.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatSearch.java new file mode 100644 index 0000000..9aea7d9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatSearch.java @@ -0,0 +1,131 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Minimal search state & async search runner for BetterChat, styled like ClickGuiSearch. + * Rendering handled elsewhere; this class only manages input text and results. + */ +public enum BetterChatSearch { + ; + private static final AtomicLong REQUEST = new AtomicLong(0); + private static final int MAX_QUERY_CHARS = 256; + private static boolean active = false; + private static String text = ""; + private static volatile boolean busy = false; + private static volatile List results = null; + + public static boolean isActive() { + return active; + } + + public static void setActive(boolean v) { + active = v; + } + + public static void deactivate() { + active = false; + reset(); + } + + public static String getText() { + return text; + } + + public static void setText(String value) { + if (value == null || value.isEmpty()) { + text = ""; + results = null; + busy = false; + return; + } + String cleaned = value.replace("\r", " ").replace("\n", " "); + text = cleaned.length() > MAX_QUERY_CHARS ? cleaned.substring(0, MAX_QUERY_CHARS) : cleaned; + } + + public static boolean hasQuery() { + return !text.isEmpty(); + } + + public static boolean isBusy() { + return busy; + } + + public static List results() { + return results; + } + + public static void append(char c) { + if (c < 32) return; + if (text.length() >= MAX_QUERY_CHARS) return; + text += c; + } + + public static void append(String value) { + if (value == null || value.isEmpty()) return; + String cleaned = value.replace("\r", " ").replace("\n", " "); + if (cleaned.isEmpty()) return; + int room = MAX_QUERY_CHARS - text.length(); + if (room <= 0) return; + text += cleaned.length() > room ? cleaned.substring(0, room) : cleaned; + } + + public static void clearText() { + text = ""; + results = null; + busy = false; + } + + public static void backspace() { + if (text.isEmpty()) return; + text = text.substring(0, text.length() - 1); + } + + public static void reset() { + text = ""; + busy = false; + results = null; + } + + /** + * Start async search across full store. + */ + public static void startSearch(BetterChatStore store) { + if (store == null) { + busy = false; + return; + } + if (text.isEmpty()) { + busy = false; + results = null; + return; + } + final long req = REQUEST.incrementAndGet(); + busy = true; + String needle = text.toLowerCase(Locale.ROOT); + CompletableFuture.runAsync(() -> { + List snap = store.snapshot(); + List out = new ArrayList<>(snap.size()); + for (ChatLine line : snap) { + if (line.text().getString().toLowerCase(Locale.ROOT).contains(needle)) { + out.add(line); + } + } + if (REQUEST.get() == req) { + results = out; + busy = false; + } + }); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStore.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStore.java new file mode 100644 index 0000000..bc9ac5f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStore.java @@ -0,0 +1,67 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; + +import java.util.ArrayList; +import java.util.List; + +public final class BetterChatStore { + + private final List lines = new ArrayList<>(); + private long revision = 0L; + + public synchronized void add(Component text) { + add(text, System.currentTimeMillis()); + } + + public synchronized void add(Component text, long timestampMs) { + lines.add(new ChatLine(text, timestampMs)); + int limit = currentLimit(); + if (limit <= 0) limit = 1; // safeguard + if (lines.size() > limit) { + int overflow = lines.size() - limit; + int drop = Math.min(lines.size(), Math.max(overflow, 100)); + lines.subList(0, drop).clear(); + } + revision++; + } + + public synchronized void clear() { + lines.clear(); + revision++; + } + + public synchronized List tail(int count) { + int start = Math.max(0, lines.size() - count); + return new ArrayList<>(lines.subList(start, lines.size())); + } + + public synchronized int size() { + return lines.size(); + } + + public synchronized List snapshot() { + return new ArrayList<>(lines); + } + + public synchronized long revision() { + return revision; + } + + private int currentLimit() { + BetterChat cfg = BetterChat.get(); + if (cfg == null) return 32000; + if (!cfg.historyEnabled()) return 100; // keep near-vanilla limit + return Math.max(1, cfg.historyLimit()); + } +} + + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStoreManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStoreManager.java new file mode 100644 index 0000000..67efef2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/BetterChatStoreManager.java @@ -0,0 +1,424 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.Style; +import net.minecraft.world.item.ItemStack; +import dev.loki.lovisual.config.ConfigPaths; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import dev.loki.lovisual.util.logging.DebugLog; +import dev.loki.lovisual.util.text.LegacyTextUtil; +import dev.loki.lovisual.util.text.TextJsonUtil; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** + * Per-world chat storage + async Gson persistence. + */ +public enum BetterChatStoreManager { + ; + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + private static final Map STORES = new HashMap<>(); + private static final Map HOVER_CACHES = new HashMap<>(); + private static final Map> SAVE_JOBS = new HashMap<>(); + private static final Map> CACHE_SAVE_JOBS = new HashMap<>(); + private static final Map LAST_SAVED_REVISIONS = new HashMap<>(); + private static final Map REQUESTED_SAVE_REVISIONS = new HashMap<>(); + private static final Map SERIALIZED_LINE_CACHE = + Collections.synchronizedMap(new WeakHashMap<>()); + private static String currentKey = null; + private static BetterChatStore active = null; + private static BetterChatHoverCache activeCache = null; + + public static BetterChatStore getActiveStore(Minecraft mc) { + String key = key(mc); + if (key == null) return null; + if (!Objects.equals(key, currentKey)) { + switchStore(key); + } + return active; + } + + public static BetterChatHoverCache getActiveCache() { + return activeCache; + } + + public static void addMessage(Component text) { + Minecraft mc = Minecraft.getInstance(); + BetterChatStore store = getActiveStore(mc); + if (store == null) return; + + text = LegacyTextUtil.convertLegacyCodes(text); + + store.add(text); + boolean cacheChanged = captureHovers(text, activeCache); + BetterChatRenderer.onNewMessage(); + BetterChat cfg = BetterChat.get(); + boolean historyOn = cfg == null || cfg.historyEnabled(); + if (historyOn) { + scheduleSave(key(mc), store); + } + if (cacheChanged) { + scheduleCacheSave(key(mc), activeCache); + } + } + + public static void clearActive() { + if (active != null) { + active.clear(); + scheduleSave(currentKey, active); + } + } + + /** + * Persist current active store without clearing it. + */ + public static void flushActive() { + if (currentKey != null && active != null) { + saveSync(currentKey, active); + } + } + + private static void switchStore(String key) { + BetterChatRenderer.resetScroll(); + currentKey = key; + BetterChatHoverCache cache = cacheFor(key); + activeCache = cache; + active = STORES.computeIfAbsent(key, k -> loadSync(k, cache)); + } + + private static BetterChatStore loadSync(String key, BetterChatHoverCache cache) { + File f = fileFor(key); + if (!f.exists()) return new BetterChatStore(); + try (FileReader reader = new FileReader(f, StandardCharsets.UTF_8)) { + JsonArray arr = GSON.fromJson(reader, JsonArray.class); + BetterChatStore store = new BetterChatStore(); + if (arr != null) { + for (var el : arr) { + if (!el.isJsonObject()) continue; + JsonObject obj = el.getAsJsonObject(); + String json = obj.has("text") ? obj.get("text").getAsString() : null; + long ts = obj.has("time") ? obj.get("time").getAsLong() : System.currentTimeMillis(); + if (json != null) { + var txt = TextJsonUtil.fromJson(json); + if (txt != null) { + store.add(txt, ts); + captureHovers(txt, cache); + } + } + } + } + DebugLog.info("[BetterChat] Loaded %d messages for %s", store.snapshot().size(), key); + return store; + } catch (Exception e) { + DebugLog.error("BetterChat load failed for %s", e, key); + return new BetterChatStore(); + } + } + + private static void scheduleSave(String key, BetterChatStore store) { + if (key == null || store == null) return; + long revision = store.revision(); + synchronized (BetterChatStoreManager.class) { + long lastSaved = LAST_SAVED_REVISIONS.getOrDefault(key, -1L); + if (revision <= lastSaved) { + return; + } + REQUESTED_SAVE_REVISIONS.put(key, revision); + CompletableFuture inFlight = SAVE_JOBS.get(key); + if (inFlight != null && !inFlight.isDone()) { + return; + } + SAVE_JOBS.put(key, CompletableFuture.runAsync(() -> drainSaveQueue(key, store))); + } + } + + /** + * Flushes all pending saves and writes current state synchronously (used on shutdown). + */ + public static void flushAll() { + // wait for pending async saves + for (CompletableFuture f : new ArrayList<>(SAVE_JOBS.values())) { + try { + f.join(); + } catch (Throwable ignored) { + } + } + for (CompletableFuture f : new ArrayList<>(CACHE_SAVE_JOBS.values())) { + try { + f.join(); + } catch (Throwable ignored) { + } + } + // save active store synchronously + if (currentKey != null && active != null) { + saveSync(currentKey, active); + } + // save caches synchronously + for (var entry : HOVER_CACHES.entrySet()) { + try { + entry.getValue().save(entry.getKey()); + } catch (Exception ignored) { + } + } + } + + private static void saveSync(String key, BetterChatStore store) { + try { + SavePayload payload = captureSavePayload(store); + File f = fileFor(key); + f.getParentFile().mkdirs(); + JsonArray arr = new JsonArray(); + for (ChatLine line : payload.lines()) { + JsonObject obj = new JsonObject(); + String json = serializedText(line); + obj.addProperty("text", json); + obj.addProperty("time", line.timestampMs()); + arr.add(obj); + } + try (FileWriter writer = new FileWriter(f, StandardCharsets.UTF_8)) { + GSON.toJson(arr, writer); + } + synchronized (BetterChatStoreManager.class) { + LAST_SAVED_REVISIONS.put(key, payload.revision()); + } + } catch (Exception e) { + DebugLog.error("BetterChat save failed for %s", e, key); + } + } + + private static void drainSaveQueue(String key, BetterChatStore store) { + try { + while (true) { + long requested; + long saved; + synchronized (BetterChatStoreManager.class) { + requested = REQUESTED_SAVE_REVISIONS.getOrDefault(key, -1L); + saved = LAST_SAVED_REVISIONS.getOrDefault(key, -1L); + if (requested <= saved) { + SAVE_JOBS.remove(key); + return; + } + } + saveSync(key, store); + } + } finally { + synchronized (BetterChatStoreManager.class) { + CompletableFuture current = SAVE_JOBS.get(key); + if (current != null && current.isDone()) { + SAVE_JOBS.remove(key); + } + } + } + } + + private static SavePayload captureSavePayload(BetterChatStore store) { + synchronized (store) { + return new SavePayload(store.revision(), store.snapshot()); + } + } + + private static String serializedText(ChatLine line) { + String cached = SERIALIZED_LINE_CACHE.get(line); + if (cached != null) { + return cached; + } + String created = TextJsonUtil.toJson(line.text()); + SERIALIZED_LINE_CACHE.put(line, created); + return created; + } + + private static File fileFor(String key) { + return betterChatFile(key + ".json"); + } + + private static File betterChatFile(String fileName) { + Minecraft mc = Minecraft.getInstance(); + return mc.gameDirectory.toPath() + .resolve(ConfigPaths.root()) + .resolve("betterchat") + .resolve(fileName) + .toFile(); + } + + private static BetterChatHoverCache cacheFor(String key) { + return HOVER_CACHES.computeIfAbsent(key, BetterChatHoverCache::load); + } + + private static String key(Minecraft mc) { + // IMPORTANT: Do not split history by dimension. One store + one cache per save/server. + if (mc == null) return null; + + if (mc.hasSingleplayerServer()) { + // Singleplayer worlds stored under singleplayer/ + String levelName = mc.getSingleplayerServer() != null ? mc.getSingleplayerServer().getWorldData().getLevelName() : "world"; + return "singleplayer/" + sanitizeKeyPart(levelName); + } + + ServerData info = mc.getCurrentServer(); + if (info != null) { + // Multiplayer servers stored under servers/ (ignore port) + String host = stripPort(info.ip); + return "servers/" + sanitizeKeyPart(host); + } + + // Fallback + return "singleplayer/world"; + } + + private static String stripPort(String address) { + if (address == null) return "unknown"; + String s = address.trim(); + if (s.isEmpty()) return "unknown"; + + // IPv6 with brackets: [addr]:port + if (s.startsWith("[")) { + int end = s.indexOf(']'); + if (end > 1) { + return s.substring(1, end); + } + return s; + } + + int lastColon = s.lastIndexOf(':'); + if (lastColon > 0 && lastColon + 1 < s.length()) { + String tail = s.substring(lastColon + 1); + boolean digits = true; + for (int i = 0; i < tail.length(); i++) { + char c = tail.charAt(i); + if (c < '0' || c > '9') { + digits = false; + break; + } + } + if (digits) { + return s.substring(0, lastColon); + } + } + + return s; + } + + /** + * Sanitizes a key part so it is safe as a file path component. + */ + private static String sanitizeKeyPart(String s) { + if (s == null) return "unknown"; + String trimmed = s.trim(); + if (trimmed.isEmpty()) return "unknown"; + StringBuilder out = null; + int length = trimmed.length(); + for (int i = 0; i < length; i++) { + char c = trimmed.charAt(i); + boolean ok = (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || c == '.' + || c == '_' + || c == '-'; + if (ok) { + if (out != null) { + out.append(c); + } + continue; + } + if (out == null) { + out = new StringBuilder(length); + out.append(trimmed, 0, i); + } + out.append('_'); + } + return out == null ? trimmed : out.toString(); + } + + private static boolean captureHovers(Component text, BetterChatHoverCache cache) { + if (text == null || cache == null) return false; + final boolean[] changed = {false}; + text.visit((style, string) -> { + if (style != null) { + HoverEvent hover = style.getHoverEvent(); + if (hover instanceof HoverEvent.ShowItem(net.minecraft.world.item.ItemStackTemplate itemTemplate)) { + ItemStack item = itemTemplate.create(); + String keyId = hoverItemKey(item); + if (cache.putItem(keyId, item)) { + changed[0] = true; + } + } else if (hover instanceof HoverEvent.ShowEntity(HoverEvent.EntityTooltipInfo entity)) { + String keyId = hoverEntityKey(entity); + if (cache.putEntity(keyId, entity)) { + changed[0] = true; + } + } + } + return Optional.empty(); + }, Style.EMPTY); + return changed[0]; + } + + public static String hoverItemKey(ItemStack stack) { + String id = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + int compHash = stack.getComponents().hashCode(); + return "item:" + id + ":" + stack.getCount() + ":" + compHash; + } + + public static String hoverEntityKey(HoverEvent.EntityTooltipInfo content) { + return "ent:" + content.uuid; + } + + private static void scheduleCacheSave(String key, BetterChatHoverCache cache) { + if (key == null || cache == null) return; + CACHE_SAVE_JOBS.compute(key, (k, prev) -> { + CompletableFuture base = prev == null + ? CompletableFuture.completedFuture(null) + : prev.exceptionally(ex -> null); + return base.thenRunAsync(() -> cache.save(k)); + }); + } + + public static BetterChatRenderer.Layout loadLayout() { + return new BetterChatRenderer.Layout(16f, 120f); + } + + public static void saveLayoutAsync(float x, float yBottom) { + } + + public static boolean hasSavedLayout() { + return false; + } + + /** + * Returns saved screen height used for the layout (framebuffer pixels), or -1 if unknown. + */ + public static float getLayoutScreenHeight() { + return -1f; + } + + /** + * Update in-memory layout cache (used every render to keep flush/save consistent). + */ + public static void cacheLayout(float x, float yBottom, float screenH) { + } + + private record SavePayload(long revision, List lines) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatHoverUtil.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatHoverUtil.java new file mode 100644 index 0000000..935af13 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatHoverUtil.java @@ -0,0 +1,240 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import dev.loki.lovisual.util.item.TopEnchantUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import dev.loki.lovisual.util.item.IllegalItemUtil; +import dev.loki.lovisual.util.item.RarityColorUtil; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Hover payload helpers + cache fallbacks. + */ +public enum ChatHoverUtil { + ; + + public static HoverTip fromHover(HoverEvent hover, Minecraft mc) { + switch (hover) { + case null -> { + return null; + } + case HoverEvent.ShowText(Component value) -> { + String[] split = value.getString().split("\n"); + List lines = new ArrayList<>(split.length); + for (String s : split) lines.add(new ColoredLine(s, 0)); + return new HoverTip(lines, null, false, ItemStack.EMPTY); + } + case HoverEvent.ShowItem(net.minecraft.world.item.ItemStackTemplate item) -> { + ItemStack stack = item.create(); + BetterChatHoverCache cache = BetterChatStoreManager.getActiveCache(); + boolean cachedFlag = false; + if (cache != null) { + String key = BetterChatStoreManager.hoverItemKey(stack); + String id = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(); + boolean cacheLoaded = cache.wasLoadedFromDisk(); + ItemStack cached = cache.getItemByKeyOrId(key, id); + if (!cached.isEmpty()) { + stack = cached; + cachedFlag = cacheLoaded && cache.wasItemKeyLoaded(key); + DebugLog.info("[BetterChat][Hover] cache hit for item %s", id); + } else { + BetterChatHoverCache.ItemLookup lookup = cache.findItemByDisplayNameWithMeta(stack.getHoverName().getString()); + if (!lookup.stack().isEmpty()) { + stack = lookup.stack(); + cachedFlag = cacheLoaded && lookup.loadedFromFile(); + DebugLog.info("[BetterChat][Hover] cache name-hit for item %s", stack.getHoverName().getString()); + } else { + DebugLog.info("[BetterChat][Hover] cache miss for item %s", id); + } + } + } + return buildItemTip(stack, mc, cachedFlag); + } + case HoverEvent.ShowEntity(HoverEvent.EntityTooltipInfo entity) -> { + BetterChatHoverCache cache = BetterChatStoreManager.getActiveCache(); + boolean cachedFlag = false; + if (cache != null) { + String key = BetterChatStoreManager.hoverEntityKey(entity); + cachedFlag = cache.wasLoadedFromDisk() && cache.wasEntityKeyLoaded(key); + } + return buildEntityTip(entity, mc, cachedFlag); + } + default -> { + } + } + return null; + } + + public static HoverTip buildItemTip(ItemStack stack, Minecraft mc, boolean fromCache) { + if (stack == null) return null; + ItemStack copy = stack.copy(); + List lines = new ArrayList<>(); + String baseName = copy.getHoverName().getString(); + String name = "[" + baseName + "]"; + int count = copy.getCount(); + int rarityColor = argbFrom(RarityColorUtil.INSTANCE.getRarityColor(copy)); + int illegalColor = IllegalItemUtil.illegalColor(); + int topColor = TopEnchantUtil.topColor(); + boolean[] hasIllegal = new boolean[1]; + boolean[] hasTop = new boolean[1]; + Set shownEnchantLabels = new HashSet<>(); + java.util.Map labelIndex = new java.util.HashMap<>(); + + IllegalItemUtil.collectEnchants(copy, (label, overMax) -> { + int enchColor = overMax ? illegalColor : 0xFFFFFFFF; + if (overMax) { + hasIllegal[0] = true; + } + shownEnchantLabels.add(label); + int idx = lines.size(); + lines.add(new ColoredLine(label, enchColor)); + labelIndex.put(label, idx); + }); + TopEnchantUtil.collectTopEnchants(copy, (label, isTop) -> { + if (!isTop) return; + hasTop[0] = true; + if (labelIndex.containsKey(label)) { + int idx = labelIndex.get(label); + ColoredLine current = lines.get(idx); + if (current.color() != illegalColor) { + lines.set(idx, new ColoredLine(label, topColor)); + } + } else { + shownEnchantLabels.add(label); + int idx = lines.size(); + lines.add(new ColoredLine(label, topColor)); + labelIndex.put(label, idx); + } + }); + + int nameColor = hasIllegal[0] ? illegalColor : (hasTop[0] ? topColor : rarityColor); + lines.addFirst(new ColoredLine(count > 1 ? name + " x" + count : name, nameColor)); + + try { + List vanilla = copy.getTooltipLines(Item.TooltipContext.EMPTY, mc.player, TooltipFlag.NORMAL); + for (int i = 0; i < vanilla.size(); i++) { + if (i == 0) continue; + Component t = vanilla.get(i); + String s = t.getString(); + int c = t.getStyle().getColor() != null ? 0xFF000000 | t.getStyle().getColor().getValue() : 0xFFFFFFFF; + String trimmed = s.trim(); + if (!trimmed.isEmpty() && shownEnchantLabels.contains(trimmed)) { + continue; + } + if (hasIllegal[0] && c == 0xFFFFFFFF) c = illegalColor; + lines.add(new ColoredLine(s, c)); + } + } catch (Throwable ignored) { + } + + lines.add(new ColoredLine("ID: " + BuiltInRegistries.ITEM.getKey(copy.getItem()), 0xFF888888)); + if (fromCache) { + lines.add(new ColoredLine(I18n.get("better_chat.hover.cached"), 0xFFFFD700)); + } + return new HoverTip(lines, null, fromCache, copy); + } + + public static HoverTip buildEntityTip(HoverEvent.EntityTooltipInfo content, Minecraft mc, boolean fromCache) { + if (content == null) return null; + List lines = new ArrayList<>(); + content.name.ifPresent(t -> lines.add(new ColoredLine(t.getString(), 0))); + lines.add(new ColoredLine(I18n.get("better_chat.hover.entity", content.type.getDescription().getString()), 0)); + lines.add(new ColoredLine("UUID: " + content.uuid, 0xFF888888)); + boolean online = false; + if (mc != null && mc.player != null && mc.player.connection != null) { + online = mc.player.connection.getOnlinePlayers().stream() + .anyMatch(e -> e.getProfile() != null && e.getProfile().id().equals(content.uuid)); + } + if (online) { + lines.add(new ColoredLine(I18n.get("better_chat.hover.online_player"), 0xFF8CF0FF)); + } else if (fromCache) { + lines.add(new ColoredLine(I18n.get("better_chat.hover.cached"), 0xFFFFD700)); + } + return new HoverTip(lines, content.uuid.toString(), fromCache, ItemStack.EMPTY); + } + + /** + * Quick heuristic tooltip by nickname from playerlist, used when hover data was not persisted with history. + */ + public static HoverTip inferFromNick(String nick, Minecraft mc) { + if (nick == null || nick.isEmpty() || mc == null || mc.player == null || mc.player.connection == null) + return null; + PlayerInfo entry = mc.player.connection.getOnlinePlayers() + .stream() + .filter(e -> e.getProfile() != null && nick.equalsIgnoreCase(e.getProfile().name())) + .findFirst() + .orElse(null); + if (entry == null || entry.getProfile() == null) return null; + String name = entry.getProfile().name(); + String uuid = entry.getProfile().id().toString(); + List lines = List.of( + new ColoredLine(name, 0), + new ColoredLine(I18n.get("better_chat.hover.online_player"), 0xFF8CF0FF), + new ColoredLine("UUID: " + uuid, 0xFF888888) + ); + return new HoverTip(lines, uuid, false, ItemStack.EMPTY); + } + + /** + * Fallback: find matching item/entity by display text ("[имя]" или "<<имя>>") or plain name. + */ + @SuppressWarnings("unused") + public static HoverTip inferFromDisplay(String rawWord, boolean looksLikeItem, Minecraft mc) { + BetterChatHoverCache cache = BetterChatStoreManager.getActiveCache(); + if (cache == null) return null; + boolean cacheLoaded = cache.wasLoadedFromDisk(); + + String cleaned = rawWord.replaceAll("[\\[\\]<>«»]", "").trim(); + if (!cleaned.isEmpty()) { + BetterChatHoverCache.ItemLookup lookup = cache.findItemByDisplayNameWithMeta(cleaned); + if (!lookup.stack().isEmpty()) { + return buildItemTip(lookup.stack(), mc, cacheLoaded && lookup.loadedFromFile()); + } + } + + BetterChatHoverCache.EntityLookup entLookup = cache.findEntityByNameWithMeta(rawWord); + if (entLookup.content() != null) { + return buildEntityTip(entLookup.content(), mc, cacheLoaded && entLookup.loadedFromFile()); + } + return null; + } + + public static int argbFrom(float[] rgba) { + if (rgba == null || rgba.length < 3) return 0xFFFFFFFF; + float r = rgba[0], g = rgba[1], b = rgba[2]; + float a = rgba.length > 3 ? rgba[3] : 1f; + int ai = (int) (a * 255) & 0xFF; + int ri = (int) (r * 255) & 0xFF; + int gi = (int) (g * 255) & 0xFF; + int bi = (int) (b * 255) & 0xFF; + return (ai << 24) | (ri << 16) | (gi << 8) | bi; + } + + public record ColoredLine(String text, int color) { + } + + public record HoverTip(List lines, String uuid, boolean cached, ItemStack item) { + public boolean cached() { + return cached; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatLine.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatLine.java new file mode 100644 index 0000000..8859d79 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/ChatLine.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import net.minecraft.network.chat.Component; + +public record ChatLine(Component text, long timestampMs) { + public float ageSeconds() { + return (System.currentTimeMillis() - timestampMs) / 1000f; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/CommandSuggestorBridge.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/CommandSuggestorBridge.java new file mode 100644 index 0000000..662c990 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/chat/CommandSuggestorBridge.java @@ -0,0 +1,259 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.chat; + +import com.mojang.brigadier.context.StringRange; +import com.mojang.brigadier.suggestion.Suggestion; +import dev.loki.lovisual.util.logging.DebugLog; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.CommandSuggestions; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.client.renderer.Rect2i; +import dev.loki.lovisual.mixins.accessors.ChatInputSuggestorAccessor; +import dev.loki.lovisual.mixins.accessors.ChatScreenAccessor; +import dev.loki.lovisual.mixins.accessors.ChatScreenSuggestorAccessor; +import dev.loki.lovisual.mixins.accessors.SuggestionWindowAccessor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; + +/** + * Reflection helpers for vanilla ChatInputSuggestor; lets us draw suggestions in custom style. + */ +public enum CommandSuggestorBridge { + ; + + private static CommandSuggestions.SuggestionsList lastWindow; + private static String lastError = ""; + + /** + * Peek current suggestor window without mutating state. + */ + public static CommandSuggestions.SuggestionsList peekWindow(ChatScreen screen) { + try { + Object suggestor = findSuggestor(screen); + if (suggestor == null) return null; + CommandSuggestions s = (CommandSuggestions) suggestor; + return ((ChatInputSuggestorAccessor) s).getCombatant$window(); + } catch (Throwable ignored) { + return null; + } + } + + public static SuggestionSnapshot snapshot(ChatScreen screen) { + try { + Object suggestor = findSuggestor(screen); + if (suggestor == null) { + note("no suggestor"); + return null; + } + CommandSuggestions s = (CommandSuggestions) suggestor; + CommandSuggestions.SuggestionsList window = ((ChatInputSuggestorAccessor) s).getCombatant$window(); + + CommandContext ctx = commandContext(screen); + List manual = buildTellLikeSuggestions(ctx); + + if (window == null) { + if (manual.isEmpty()) { + note("no window"); + return null; + } + List merged = mergeSuggestions(List.of(), manual); + List texts = collectTexts(merged); + int selection = 0; + int start = 0; + int visible = Math.min(5, Math.max(1, merged.size())); + note("manual"); + lastWindow = null; + return new SuggestionSnapshot(0, 0, 0, 0, texts, merged, selection, start, visible); + } + + SuggestionWindowAccessor acc = (SuggestionWindowAccessor) window; + Rect2i area = acc.getCombatant$area(); + int x = area.getX(); + int y = area.getY(); + int w = area.getWidth(); + int h = area.getHeight(); + + List suggestions = collectSuggestions(acc.getCombatant$suggestions(), currentToken(screen), isCommand(screen)); + suggestions = mergeSuggestions(suggestions, manual); + List texts = collectTexts(suggestions); + int selection = clamp(acc.getCombatant$selection(), suggestions.size()); + int start = clamp(acc.getCombatant$inWindowIndex(), suggestions.size()); + int visible = Math.max(1, h / 12); + + note("ok"); + lastWindow = window; + return new SuggestionSnapshot(x, y, w, h, texts, suggestions, selection, start, visible); + } catch (Throwable ignored) { + note("exception"); + return null; + } + } + + private static void note(String msg) { + if (!msg.equals(lastError)) { + DebugLog.info("[BetterChat] Suggest snapshot: %s", msg); + lastError = msg; + } + } + + private static Object findSuggestor(ChatScreen screen) { + if (screen instanceof ChatScreenSuggestorAccessor acc) { + return acc.getCombatant$suggestor(); + } + return null; + } + + @SuppressWarnings("unused") + private static List collectSuggestions(List list, String token, boolean isCommand) { + if (list == null || list.isEmpty()) return List.of(); + List out = new ArrayList<>(list.size()); + for (Object obj : list) { + if (obj instanceof Suggestion s) { + out.add(s); + } else if (obj != null) { + out.add(new Suggestion(StringRange.at(0), obj.toString())); + } + } + return out; + } + + private static List collectTexts(List suggestions) { + if (suggestions == null || suggestions.isEmpty()) return List.of(); + List texts = new ArrayList<>(suggestions.size()); + for (Suggestion s : suggestions) { + texts.add(s.getText()); + } + return texts; + } + + public static CommandSuggestions.SuggestionsList lastWindow() { + return lastWindow; + } + + private static String currentToken(ChatScreen screen) { + try { + EditBox field = ((ChatScreenAccessor) screen).getChatField(); + if (field == null) return ""; + String text = field.getValue(); + int cursor = field.getCursorPosition(); + if (cursor > text.length()) cursor = text.length(); + String before = text.substring(0, cursor); + int lastSpace = before.lastIndexOf(' '); + String token = lastSpace >= 0 ? before.substring(lastSpace + 1) : before; + return token.trim(); + } catch (Throwable ignored) { + return ""; + } + } + + private static boolean isCommand(ChatScreen screen) { + try { + EditBox field = ((ChatScreenAccessor) screen).getChatField(); + if (field == null) return false; + String text = field.getValue(); + return text != null && text.startsWith("/"); + } catch (Throwable ignored) { + return false; + } + } + + private static CommandContext commandContext(ChatScreen screen) { + try { + EditBox field = ((ChatScreenAccessor) screen).getChatField(); + if (field == null) return null; + String text = field.getValue(); + if (text == null || !text.startsWith("/")) return null; + int cursor = Math.min(field.getCursorPosition(), text.length()); + String before = text.substring(0, cursor); + String afterSlash = before.substring(1); + String[] parts = afterSlash.split("\\s+", -1); + if (parts.length == 0) return null; + String cmd = parts[0].toLowerCase(Locale.ROOT); + int argIndex = parts.length - 1; + String token = parts[parts.length - 1]; + int tokenStart = before.lastIndexOf(' '); + tokenStart = tokenStart >= 0 ? tokenStart + 1 : 1; + return new CommandContext(cmd, argIndex, token, tokenStart); + } catch (Throwable ignored) { + return null; + } + } + + private static List buildTellLikeSuggestions(CommandContext ctx) { + if (ctx == null) return List.of(); + if (ctx.argIndex != 1) return List.of(); + if (!(ctx.command.equals("tell") || ctx.command.equals("msg") || ctx.command.equals("m"))) return List.of(); + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getConnection() == null) return List.of(); + String tokenLower = ctx.token.toLowerCase(Locale.ROOT); + int start = Math.max(0, ctx.tokenStart); + int end = Math.max(start, start + ctx.token.length()); + List out = new ArrayList<>(); + String self = mc.getUser().getName(); + for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) { + String name = entry.getProfile().name(); + if (name == null || name.isEmpty()) continue; + if (name.equals(self)) continue; + String lower = name.toLowerCase(Locale.ROOT); + if (!tokenLower.isEmpty() && !lower.startsWith(tokenLower)) continue; + out.add(new Suggestion(StringRange.between(start, end), name)); + } + return out; + } + + private static List mergeSuggestions(List base, List extra) { + if ((base == null || base.isEmpty()) && (extra == null || extra.isEmpty())) return List.of(); + LinkedHashMap map = new LinkedHashMap<>(); + if (base != null) { + for (Suggestion s : base) { + if (s == null || s.getText() == null) continue; + map.put(s.getText(), s); + } + } + if (extra != null) { + for (Suggestion s : extra) { + if (s == null || s.getText() == null) continue; + map.putIfAbsent(s.getText(), s); + } + } + return new ArrayList<>(map.values()); + } + + private static int clamp(int idx, int size) { + if (size <= 0) return 0; + return Math.max(0, Math.min(idx, size - 1)); + } + + private static boolean looksLikeName(String text) { + if (text == null || text.isEmpty()) return false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (!(Character.isLetterOrDigit(c) || c == '_' || c == '-')) return false; + } + return true; + } + + public record SuggestionSnapshot(int x, int y, int w, int h, + List texts, + List suggestions, + int selection, + int startIndex, + int visibleCount) { + } + + private record CommandContext(String command, int argIndex, String token, int tokenStart) { + } +} + + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiEditorScreen.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiEditorScreen.java new file mode 100644 index 0000000..4d571cd --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiEditorScreen.java @@ -0,0 +1,64 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +public final class ClickGuiEditorScreen extends Screen { + + public ClickGuiEditorScreen() { + super(Component.literal("ClickGui Editor")); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // Render is intentionally deferred to GameRendererMixin after GuiRenderer.render(...). + // Vanilla HUD/screen elements are queued into GuiRenderState and otherwise flush above immediate ClickGui rendering. + } + + @Override + public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // No vanilla blur/darkening behind editor. + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + ClickGuiRenderer.onEditorMouseMoveScaled(mouseX, mouseY); + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + ClickGuiRenderer.onEditorMouseButtonScaled(click.x(), click.y(), click.button(), true); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + ClickGuiRenderer.onEditorMouseButtonScaled(click.x(), click.y(), click.button(), false); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + ClickGuiRenderer.onEditorMouseScrollScaled(mouseX, mouseY, verticalAmount); + return true; + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + ClickGuiRenderer.closeGuiEditor(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiPickerScreen.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiPickerScreen.java new file mode 100644 index 0000000..497a77a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiPickerScreen.java @@ -0,0 +1,64 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +public final class ClickGuiPickerScreen extends Screen { + + public ClickGuiPickerScreen() { + super(Component.literal("ClickGui Picker")); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // Render is intentionally deferred to GameRendererMixin after GuiRenderer.render(...). + // Vanilla HUD/screen elements are queued into GuiRenderState and otherwise flush above immediate ClickGui rendering. + } + + @Override + public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // No vanilla blur/darkening behind ClickGUI picker. + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + ClickGuiRenderer.onMouseMoveScaled(mouseX, mouseY); + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + ClickGuiRenderer.onMouseButtonScaled(click.x(), click.y(), click.button(), true); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + ClickGuiRenderer.onMouseButtonScaled(click.x(), click.y(), click.button(), false); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + ClickGuiRenderer.onMouseScrollScaled(mouseX, mouseY, verticalAmount); + return true; + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + ClickGuiRenderer.closePickerScreen(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiRenderer.java new file mode 100644 index 0000000..4ccdac7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiRenderer.java @@ -0,0 +1,1900 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui; + +import dev.loki.lovisual.addon.ClickGuiSectionManager; +import dev.loki.lovisual.features.gui.clickgui.settings.*; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.util.StringUtil; +import net.minecraft.world.item.ItemStack; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.features.gui.clickgui.editor.ClickGuiTextEditorState; +import dev.loki.lovisual.features.gui.clickgui.picker.ClickGuiPickerState; +import dev.loki.lovisual.features.gui.clickgui.protocol.CombatProtocolHeuristicsEditorState; +import dev.loki.lovisual.features.gui.clickgui.sections.ClickGuiSection; +import dev.loki.lovisual.features.gui.clickgui.sections.ModulesSection; +import dev.loki.lovisual.features.gui.clickgui.sections.SettingsSection; +import dev.loki.lovisual.features.gui.clickgui.sections.settings.SettingsTabRuntime; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.impl.DynamicIsland; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.mixininterface.IGuiGraphics; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.core.CombatantRenderSystem; +import dev.loki.lovisual.render.engine.core.RenderPhase; +import dev.loki.lovisual.render.engine.core.RenderPhaseScope; +import dev.loki.lovisual.render.engine.core.ViewportContext; +import dev.loki.lovisual.render.engine.profiler.ProfilerPhase; +import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D; +import dev.loki.lovisual.render.engine.profiler.TracyGpuProfiler; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.input.KeyUtil; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.util.wav.ClickGuiSounds; + +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +public enum ClickGuiRenderer { + ; + + private static final Minecraft MC = Minecraft.getInstance(); + private static final ModulesSection MODULES_SECTION = new ModulesSection(); + private static final SettingsSection SETTINGS_SECTION = new SettingsSection(); + private static final String MODULES_TAB_ID = "combatant:modules"; + private static final String SETTINGS_TAB_ID = "combatant:settings"; + private static final float TAB_HEIGHT = 34f; + private static final float TAB_GAP = 0f; + private static final float TAB_PAD_X = 18f; + private static final float TAB_FONT = 17f; + private static final float TAB_MIN_W = 96f; + private static float[] tabX = new float[0]; + private static float[] tabW = new float[0]; + private static final float LIFECYCLE_OPEN_SPEED = 9.5f; + private static final float LIFECYCLE_CLOSE_SPEED = 8.0f; + private static final float INPUT_READY_PROGRESS = 0.72f; + private static final Set pendingBindKeys = new LinkedHashSet<>(); + private static final List pickerIcons = new ArrayList<>(); + private static final java.util.ArrayList TEXT_BATCH = new java.util.ArrayList<>(); + private static final int TEXT_WIDTH_CACHE_LIMIT = 4096; + private static final int TEXT_HEIGHT_CACHE_LIMIT = 256; + private static final Map TEXT_WIDTH_CACHE = new LinkedHashMap<>(512, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > TEXT_WIDTH_CACHE_LIMIT; + } + }; + private static final Map TEXT_HEIGHT_CACHE = new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > TEXT_HEIGHT_CACHE_LIMIT; + } + }; + public static boolean waitingForKey = false; + public static Object keyBindOwner = null; + private static int colorClipboard = 0xFFFFFFFF; + private static boolean init = false; + private static float mouseX, mouseY; + private static boolean cursorShown = false; + private static int fbWidth, fbHeight; + private static TextRenderer interRegular; + private static TextRenderer interMedium; + private static TextRenderer onestMedium; + private static TextRenderer onestBold; + private static TextRenderer monsterratRegular; + private static String activeTabId = MODULES_TAB_ID; + private static float tabBarX, tabBarY, tabBarW, tabBarH; + private static float tabTargetX, tabTargetW; + private static float tabAnimX, tabAnimW; + private static boolean tabAnimInit = false; + private static long lastTabAnimNs = System.nanoTime(); + private static float lifecycleAnim = 0.0f; + private static boolean closingForExit = false; + private static boolean finishingClose = false; + private static long lastLifecycleAnimNs = System.nanoTime(); + private static float modulesAreaX, modulesAreaY, modulesAreaW, modulesAreaH; + private static CompletableFuture pendingSaveTask; + private static ClickGuiTextEditorState textEditor = null; + private static ClickGuiPickerState picker = null; + private static CombatProtocolHeuristicsEditorState protocolHeuristicsEditor = null; + private static TextSetting inlineTextOwner = null; + private static StringBuilder inlineTextBuffer = null; + private static float inlineTextX, inlineTextY, inlineTextW, inlineTextH; + private static boolean pickerIconScissorActive = false; + private static float pickerIconScissorX, pickerIconScissorY, pickerIconScissorW, pickerIconScissorH; + private static Renderer2D renderer; + private static float renderAlphaMultiplier = 1.0f; + private static ClickGuiScreen mainScreen; + private static ClickGuiPickerScreen pickerScreen; + private static boolean textBatchActive; + + // ========================= + // API for MIXIN MOUSE + // ========================= + public static void onMouseMove(double x, double y) { + updateMouse(x, y, false); + if (picker != null) { + picker.handleMouseMove(mouseX, mouseY); + } + if (protocolHeuristicsEditor != null) { + protocolHeuristicsEditor.handleMouseMove(mouseX, mouseY); + } + if (textEditor != null) { + textEditor.handleMouseMove(mouseX, mouseY); + } + } + + public static void onMouseButton(double x, double y, int button, boolean pressed) { + updateMouse(x, y, false); + handleMouseButton(button, pressed); + } + + public static void onMouseScroll(double x, double y, double delta) { + updateMouse(x, y, false); + handleMouseScroll(delta); + } + + public static void onMouseMoveScaled(double x, double y) { + updateMouse(x, y, true); + if (picker != null) { + picker.handleMouseMove(mouseX, mouseY); + } + if (protocolHeuristicsEditor != null) { + protocolHeuristicsEditor.handleMouseMove(mouseX, mouseY); + } + if (textEditor != null) { + textEditor.handleMouseMove(mouseX, mouseY); + } + } + + public static void onMouseButtonScaled(double x, double y, int button, boolean pressed) { + updateMouse(x, y, true); + handleMouseButton(button, pressed); + } + + public static void onMouseScrollScaled(double x, double y, double delta) { + updateMouse(x, y, true); + handleMouseScroll(delta); + } + + public static void onEditorMouseMoveScaled(double x, double y) { + updateMouse(x, y, true); + } + + public static void onEditorMouseButtonScaled(double x, double y, int button, boolean pressed) { + updateMouse(x, y, true); + if (waitingForKey) { + if (pressed) addPendingMouseButton(button); + return; + } + if (textEditor != null) { + if (pressed) { + textEditor.handleMouseDown(mouseX, mouseY, button); + } else { + textEditor.handleMouseUp(mouseX, mouseY, button); + } + return; + } + if (isSettingsTabActive()) { + SettingsTabRuntime.handleEditorMouseButton(mouseX, mouseY, button, pressed); + } + } + + public static void onEditorMouseScrollScaled(double x, double y, double delta) { + updateMouse(x, y, true); + if (delta != 0.0) { + ClickGuiSounds.scroll(); + } + if (textEditor != null) { + textEditor.scroll(delta); + return; + } + if (isSettingsTabActive()) { + SettingsTabRuntime.handleEditorMouseScroll(mouseX, mouseY, delta); + } + } + + public static boolean onEditorCharTyped(char c) { + return onCharTyped(c); + } + + public static boolean onEditorKey(int key, int scancode, int action, int mods) { + return onKey(key, scancode, action, mods); + } + + public static void closeGuiEditor() { + SettingsTabRuntime.closeGuiEditor(); + } + + private static void handleMouseButton(int button, boolean pressed) { + if (!isInputReady()) return; + if (waitingForKey) { + if (pressed) addPendingMouseButton(button); + return; + } + + if (textEditor != null) { + if (pressed) { + textEditor.handleMouseDown(mouseX, mouseY, button); + } else { + textEditor.handleMouseUp(mouseX, mouseY, button); + } + return; + } + + if (protocolHeuristicsEditor != null) { + if (pressed) { + protocolHeuristicsEditor.handleMouseDown(mouseX, mouseY, button); + } else { + protocolHeuristicsEditor.handleMouseUp(mouseX, mouseY, button); + } + return; + } + + if (pressed && handleTabClick(mouseX, mouseY)) { + return; + } + + if (picker != null) { + if (pressed) { + picker.handleMouseDown(mouseX, mouseY, button); + } else { + picker.handleMouseUp(mouseX, mouseY, button); + } + return; + } + + if (!pressed) { + getActiveSection().mouseReleased(mouseX, mouseY, button); + return; + } + + getActiveSection().mousePressed(mouseX, mouseY, button); + } + + private static void handleMouseScroll(double delta) { + if (!isInputReady()) return; + if (delta != 0.0) { + ClickGuiSounds.scroll(); + } + + if (textEditor != null) { + textEditor.scroll(delta); + return; + } + + if (protocolHeuristicsEditor != null) { + protocolHeuristicsEditor.scroll(delta); + return; + } + + if (picker != null) { + picker.scroll(delta); + return; + } + + getActiveSection().mouseScrolled(mouseX, mouseY, delta); + } + + private static void updateMouse(double x, double y, boolean fromScaledCoords) { + if (MC == null || MC.getWindow() == null) { + mouseX = (float) x; + mouseY = (float) y; + return; + } + if (fromScaledCoords) { + double scaledW = MC.getWindow().getGuiScaledWidth(); + double scaledH = MC.getWindow().getGuiScaledHeight(); + double fbW = MC.getWindow().getWidth(); + double fbH = MC.getWindow().getHeight(); + double sx = scaledW > 0 ? fbW / scaledW : MC.getWindow().getGuiScale(); + double sy = scaledH > 0 ? fbH / scaledH : MC.getWindow().getGuiScale(); + double fbX = x * sx; + double fbY = y * sy; + float uiScale = HudScale.scale((int) fbW, (int) fbH); + mouseX = (float) (fbX / uiScale); + mouseY = (float) (fbY / uiScale); + return; + } + double scale = MC.getWindow().getGuiScale(); + double fbW = MC.getWindow().getWidth(); + double fbH = MC.getWindow().getHeight(); + double fbX = x * scale; + double fbY = y * scale; + float uiScale = HudScale.scale((int) fbW, (int) fbH); + mouseX = (float) (fbX / uiScale); + mouseY = (float) (fbY / uiScale); + } + + // ========================= + // API for MIXIN KEYBOARD + // ========================= + public static boolean onCharTyped(char c) { + if (!ModuleManager.isEnabled("clickgui")) return false; + if (textEditor != null) { + textEditor.insertChar(c); + return true; + } + if (picker != null) { + if (picker.isListening()) { + picker.insertChar(c); + return true; + } + return false; + } + if (protocolHeuristicsEditor != null) { + return protocolHeuristicsEditor.charTyped(c); + } + if (inlineTextOwner != null && inlineTextBuffer != null) { + if (StringUtil.isAllowedChatCharacter(c)) { + inlineTextBuffer.append(c); + return true; + } + return false; + } + return getActiveSection().charTyped(c, 0); + } + + public static boolean onFilesDrop(List paths) { + if (!ModuleManager.isEnabled("clickgui")) return false; + if (textEditor != null || picker != null || protocolHeuristicsEditor != null) return false; + return getActiveSection().onFilesDrop(paths); + } + + public static boolean onKey(int key, int scancode, int action, int mods) { + if (!ModuleManager.isEnabled("clickgui")) return false; + + if (textEditor != null) { + if (action == GLFW.GLFW_PRESS || action == GLFW.GLFW_REPEAT) { + boolean shift = (mods & GLFW.GLFW_MOD_SHIFT) != 0; + boolean ctrl = (mods & GLFW.GLFW_MOD_CONTROL) != 0; + switch (key) { + case GLFW.GLFW_KEY_ESCAPE -> closeTextEditor(false); + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> textEditor.insertNewline(); + case GLFW.GLFW_KEY_BACKSPACE -> textEditor.backspace(); + case GLFW.GLFW_KEY_DELETE -> textEditor.deleteForward(); + case GLFW.GLFW_KEY_LEFT -> textEditor.moveCaret(-1, shift); + case GLFW.GLFW_KEY_RIGHT -> textEditor.moveCaret(1, shift); + case GLFW.GLFW_KEY_UP -> textEditor.moveCaretVertical(-1, shift); + case GLFW.GLFW_KEY_DOWN -> textEditor.moveCaretVertical(1, shift); + case GLFW.GLFW_KEY_HOME -> textEditor.moveCaretToStart(shift); + case GLFW.GLFW_KEY_END -> textEditor.moveCaretToEnd(shift); + default -> { + if (key == GLFW.GLFW_KEY_V && ctrl) textEditor.pasteFromClipboard(); + if (key == GLFW.GLFW_KEY_C && ctrl) textEditor.copySelection(); + if (key == GLFW.GLFW_KEY_A && ctrl) textEditor.selectAll(); + } + } + } + return true; + } + + if (protocolHeuristicsEditor != null) { + return protocolHeuristicsEditor.keyPressed(key, scancode, action, mods); + } + + if (picker != null) { + if (action == GLFW.GLFW_PRESS || action == GLFW.GLFW_REPEAT) { + boolean ctrl = (mods & GLFW.GLFW_MOD_CONTROL) != 0; + switch (key) { + case GLFW.GLFW_KEY_ESCAPE -> closePicker(); + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> picker.stopListening(); + case GLFW.GLFW_KEY_BACKSPACE -> picker.backspace(); + default -> { + if (key == GLFW.GLFW_KEY_F && ctrl) picker.toggleListening(); + } + } + } + return true; + } + + if (inlineTextOwner != null && inlineTextBuffer != null) { + if (action == GLFW.GLFW_PRESS || action == GLFW.GLFW_REPEAT) { + boolean ctrl = (mods & GLFW.GLFW_MOD_CONTROL) != 0; + switch (key) { + case GLFW.GLFW_KEY_ESCAPE -> finishInlineText(false); + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> finishInlineText(true); + case GLFW.GLFW_KEY_BACKSPACE, GLFW.GLFW_KEY_DELETE -> inlineBackspace(); + default -> { + if (key == GLFW.GLFW_KEY_V && ctrl) inlinePaste(); + } + } + } + return true; + } + + if (waitingForKey) { + + if (key == GLFW.GLFW_KEY_ESCAPE) { + submitKeySelection((String) null); + finishKeyBind(); + return true; + } + + if (action == GLFW.GLFW_PRESS) { + if (key == GLFW.GLFW_KEY_ENTER || key == GLFW.GLFW_KEY_KP_ENTER) { + submitKeySelection(pendingBindKeys); + finishKeyBind(); + return true; + } + + addPendingKey(key, scancode); + } + + return true; + } + + if (action == GLFW.GLFW_PRESS || action == GLFW.GLFW_REPEAT) { + return getActiveSection().keyPressed(key, scancode, mods); + } + return false; + } + + // ========================= + // INIT + // ========================= + public static void init() { + if (init) return; + init = true; + activeTabId = MODULES_TAB_ID; + for (ClickGuiTabEntry tab : currentTabs()) { + if (MODULES_TAB_ID.equals(tab.id())) { + tab.section().onSelected(); + } else { + tab.section().onDeselected(); + } + } + } + + public static void beginOpenAnimation() { + init(); + closingForExit = false; + finishingClose = false; + lastLifecycleAnimNs = System.nanoTime(); + getActiveSection().onSelected(); + } + + public static void beginCloseAnimation() { + if (finishingClose) return; + init(); + closingForExit = true; + lastLifecycleAnimNs = System.nanoTime(); + ClickGuiSearch.deactivate(); + closeTextEditor(false); + clearInlineText(); + clearPicker(); + for (ClickGuiTabEntry tab : currentTabs()) { + tab.section().onDeselected(); + } + detachScreenForExitOverlay(); + releaseCursorForGameplay(); + } + + // ========================= + // CURSOR OPEN/CLOSE + // ========================= + + private static void detachScreenForExitOverlay() { + if (MC == null) return; + if (ClientScreen.current() instanceof ClickGuiScreen cs) { + mainScreen = cs; + } + if (ClientScreen.current() instanceof ClickGuiScreen + || ClientScreen.current() instanceof ClickGuiPickerScreen + || ClientScreen.current() instanceof ClickGuiEditorScreen) { + ClientScreen.show(MC, null); + } + } + + public static boolean isLifecycleActive() { + return ModuleManager.isEnabled("clickgui") || closingForExit || lifecycleAnim > 0.001f; + } + + public static boolean isClosingForExit() { + return closingForExit; + } + + private static boolean isInputReady() { + return ModuleManager.isEnabled("clickgui") && !closingForExit && lifecycleAnim >= INPUT_READY_PROGRESS; + } + + public static void finishCloseImmediately() { + closingForExit = false; + lifecycleAnim = 0.0f; + onClose(); + clearScreens(); + } + + public static void onClose() { + if (!cursorShown) return; + long handle = MC.getWindow().handle(); + + GLFW.glfwSetInputMode(handle, GLFW.GLFW_CURSOR, GLFW.GLFW_CURSOR_DISABLED); + SystemCursor.invalidate(); + cursorShown = false; + DraggableHudElementRegistry.setForceVisible(false); + ClickGuiSearch.deactivate(); + closeTextEditor(false); + clearInlineText(); + clearPicker(); + + requestConfigSave(); + } + + public static void releaseCursorForGameplay() { + if (MC == null || MC.getWindow() == null) return; + if (ClientScreen.current() == null) { + try { + MC.mouseHandler.grabMouse(); + SystemCursor.invalidate(); + } catch (Throwable ignored) { + GLFW.glfwSetInputMode(MC.getWindow().handle(), GLFW.GLFW_CURSOR, GLFW.GLFW_CURSOR_DISABLED); + SystemCursor.invalidate(); + } + } else { + GLFW.glfwSetInputMode(MC.getWindow().handle(), GLFW.GLFW_CURSOR, GLFW.GLFW_CURSOR_DISABLED); + SystemCursor.invalidate(); + } + cursorShown = false; + DraggableHudElementRegistry.setForceVisible(false); + } + + public static void ensureCursorShown() { + if (cursorShown) return; + if (MC == null || MC.getWindow() == null) return; + GLFW.glfwSetInputMode( + MC.getWindow().handle(), + GLFW.GLFW_CURSOR, + GLFW.GLFW_CURSOR_NORMAL + ); + SystemCursor.invalidate(); + cursorShown = true; + } + + public static void renderEngine(Renderer2D rendererIn, TextRenderer fallback, GuiGraphicsExtractor ctx, float tickDelta) { + try (ProfilerPhase.Scope frameScope = ProfilerPhase.scope("2d:clickgui:render"); + RenderProfiler2D.Section ignoredFrame = RenderProfiler2D.section("render")) { + boolean enabled = ModuleManager.isEnabled("clickgui"); + long now = System.nanoTime(); + boolean mainScreenActive = ClientScreen.current() instanceof ClickGuiScreen; + boolean pickerScreenActive = ClientScreen.current() instanceof ClickGuiPickerScreen; + + if (!enabled && !closingForExit && lifecycleAnim <= 0.001f) { + ClickGuiRenderer.onClose(); + return; + } + + if (ClientScreen.current() != null && !mainScreenActive && !pickerScreenActive) { + if (closingForExit) finishCloseImmediately(); + return; + } + // Screen renders ClickGUI directly; no extra gating needed here. + + init(); + if (enabled && closingForExit) { + closingForExit = false; + getActiveSection().onSelected(); + } + updateLifecycleAnim(now, enabled && !closingForExit); + renderer = rendererIn; + pickerIcons.clear(); + + SystemCursor.beginFrame(ctx); + try { + if (enabled && !closingForExit && !cursorShown) { + GLFW.glfwSetInputMode( + MC.getWindow().handle(), + GLFW.GLFW_CURSOR, + GLFW.GLFW_CURSOR_NORMAL + ); + SystemCursor.invalidate(); + cursorShown = true; + } + int fbw = MC.getWindow().getWidth(); + int fbh = MC.getWindow().getHeight(); + int vw = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int vh = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + fbWidth = vw; + fbHeight = vh; + + try (ProfilerPhase.Scope layoutScope = ProfilerPhase.scope("2d:clickgui:layout"); + RenderProfiler2D.Section ignoredLayout = RenderProfiler2D.section("layout"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:layout")) { + layout(vw, vh); + updateTabAnim(now); + } + + if (pickerScreenActive) { + try (ProfilerPhase.Scope pickerScope = ProfilerPhase.scope("2d:clickgui:modal"); + RenderProfiler2D.Section ignoredPicker = RenderProfiler2D.section("modal"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:modal")) { + if (protocolHeuristicsEditor != null) { + protocolHeuristicsEditor.render(vw, vh); + } else { + renderPicker(vw, vh); + } + } + try (ProfilerPhase.Scope textEditorScope = ProfilerPhase.scope("2d:clickgui:text_editor"); + RenderProfiler2D.Section ignoredTextEditor = RenderProfiler2D.section("text_editor"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:text_editor")) { + if (!closingForExit) { + renderTextEditor(vw, vh); + } + } + return; + } + + try (ProfilerPhase.Scope tabsScope = ProfilerPhase.scope("2d:clickgui:tabs"); + RenderProfiler2D.Section ignoredTabs = RenderProfiler2D.section("tabs"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:tabs")) { + drawTabs(); + } + + try (ProfilerPhase.Scope sectionScope = ProfilerPhase.scope("2d:clickgui:section"); + RenderProfiler2D.Section ignoredSection = RenderProfiler2D.section("section"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:section")) { + getActiveSection().render(mouseX, mouseY); + } + try (ProfilerPhase.Scope textEditorScope = ProfilerPhase.scope("2d:clickgui:text_editor"); + RenderProfiler2D.Section ignoredTextEditor = RenderProfiler2D.section("text_editor"); + TracyGpuProfiler.Scope gpuScope = TracyGpuProfiler.beginZone("2d:clickgui:text_editor")) { + if (!closingForExit) { + renderTextEditor(vw, vh); + } + } + + if (closingForExit && lifecycleAnim <= 0.001f && sectionsClosed()) { + completeCloseAnimation(); + } + } finally { + SystemCursor.endFrame(); + } + } + } + + public static boolean shouldRenderAsTopLayer() { + return MC != null && (isLifecycleActive() + || ClientScreen.current() instanceof ClickGuiScreen + || ClientScreen.current() instanceof ClickGuiPickerScreen + || ClientScreen.current() instanceof ClickGuiEditorScreen); + } + + public static void renderTopLayer(GuiGraphicsExtractor ctx, float tickDelta) { + if (!shouldRenderAsTopLayer()) return; + + CombatantRenderSystem.ensureFrameContext(); + ViewportContext.beginUnscaledLogical(ctx); + try (RenderPhaseScope phaseScope = CombatantRenderSystem.phase(RenderPhase.SCREEN_TOP, "2d:clickgui_top")) { + SystemCursor.beginFrame(ctx); + try { + Renderer2D.COLOR.begin(); + if (ClientScreen.current() instanceof ClickGuiEditorScreen) { + renderEditor(Renderer2D.COLOR, TextRenderer.get(), ctx, tickDelta); + } else { + renderEngine(Renderer2D.COLOR, TextRenderer.get(), ctx, tickDelta); + } + Renderer2D.COLOR.render(); + if (ClientScreen.current() instanceof ClickGuiPickerScreen) { + processPickerIcons(ctx); + } + } finally { + SystemCursor.endFrame(); + } + } + ViewportContext.end(ctx); + } + + public static void renderEditor(Renderer2D rendererIn, TextRenderer fallback, GuiGraphicsExtractor ctx, float tickDelta) { + if (MC == null || MC.getWindow() == null) return; + init(); + renderer = rendererIn; + int fbw = MC.getWindow().getWidth(); + int fbh = MC.getWindow().getHeight(); + int vw = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int vh = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + fbWidth = vw; + fbHeight = vh; + SystemCursor.beginFrame(ctx); + try { + SettingsTabRuntime.renderEditor(rendererIn, fallback, ctx, tickDelta, vw, vh, mouseX, mouseY); + } finally { + SystemCursor.endFrame(); + } + } + + public static void renderModulesPreview(float x, float y, float w, float h) { + if (renderer == null) return; + init(); + float pad = 8f; + float areaX = x + pad; + float areaY = y + pad; + float areaW = Math.max(1f, w - pad * 2f); + float areaH = Math.max(1f, h - pad * 2f); + MODULES_SECTION.layout(areaX, areaY, areaW, areaH); + float mx = -10_000f; + float my = -10_000f; + MODULES_SECTION.render(mx, my); + } + + private static void layout(int fbw, int fbh) { + List tabs = currentTabs(); + ensureActiveTab(tabs); + float contentMarginX = 28f; + float contentTop = 52f; + float contentBottom = 18f; + modulesAreaX = contentMarginX; + modulesAreaY = contentTop; + modulesAreaW = Math.max(1f, fbw - contentMarginX * 2f); + modulesAreaH = Math.max(1f, fbh - contentTop - contentBottom); + for (ClickGuiTabEntry tab : tabs) { + tab.section().layout(modulesAreaX, modulesAreaY, modulesAreaW, modulesAreaH); + } + + tabBarH = TAB_HEIGHT; + tabBarY = 10f; + ensureTabCapacity(tabs.size()); + float totalTabs = 0f; + for (int t = 0; t < tabs.size(); t++) { + float wTab = textWidth(getSfProDisplaySemibold(), tabs.get(t).label(), TAB_FONT) + TAB_PAD_X * 2f; + if (wTab < TAB_MIN_W) wTab = TAB_MIN_W; + tabW[t] = wTab; + totalTabs += wTab; + } + if (!tabs.isEmpty()) { + totalTabs += TAB_GAP * (tabs.size() - 1); + } + tabBarW = totalTabs; + tabBarX = (fbw - totalTabs) * 0.5f; + float cx = tabBarX; + for (int t = 0; t < tabs.size(); t++) { + tabX[t] = cx; + cx += tabW[t] + TAB_GAP; + } + + int activeIdx = 0; + for (int t = 0; t < tabs.size(); t++) { + if (tabs.get(t).id().equals(activeTabId)) { + activeIdx = t; + break; + } + } + tabTargetX = tabX[activeIdx]; + tabTargetW = tabW[activeIdx]; + if (!tabAnimInit) { + tabAnimX = tabTargetX; + tabAnimW = tabTargetW; + tabAnimInit = true; + lastTabAnimNs = System.nanoTime(); + } + } + + private static void updateTabAnim(long now) { + float dt = (now - lastTabAnimNs) / 1_000_000_000f; + lastTabAnimNs = now; + if (dt < 0f) dt = 0f; + float speed = 16f; + float step = Math.min(1f, dt * speed); + tabAnimX += (tabTargetX - tabAnimX) * step; + tabAnimW += (tabTargetW - tabAnimW) * step; + } + + private static void updateLifecycleAnim(long now, boolean opening) { + float dt = (now - lastLifecycleAnimNs) / 1_000_000_000f; + lastLifecycleAnimNs = now; + if (dt < 0f) dt = 0f; + float target = opening ? 1.0f : 0.0f; + float speed = opening ? LIFECYCLE_OPEN_SPEED : LIFECYCLE_CLOSE_SPEED; + lifecycleAnim = AnimationUtility.approach(lifecycleAnim, target, dt, speed); + lifecycleAnim = AnimationUtility.snap(lifecycleAnim, target, 0.002f); + } + + private static boolean sectionsClosed() { + return !getActiveSection().isVisible(); + } + + private static void completeCloseAnimation() { + if (finishingClose) return; + finishingClose = true; + closingForExit = false; + onClose(); + if (MC != null && (ClientScreen.current() instanceof ClickGuiScreen + || ClientScreen.current() instanceof ClickGuiPickerScreen + || ClientScreen.current() instanceof ClickGuiEditorScreen)) { + ClientScreen.show(MC, null); + } + clearScreens(); + finishingClose = false; + } + + private static void drawTabs() { + float eased = easeOutCubic(lifecycleAnim); + if (eased <= 0.001f) return; + List tabs = currentTabs(); + ensureActiveTab(tabs); + if (tabs.isEmpty()) return; + boolean islandShell = DynamicIsland.shouldOwnClickGuiTabShell(); + + TextRenderer tabFont = getSfProDisplaySemibold(); + float y = tabBarY - (1.0f - eased) * 18.0f; + float outerRadius = tabBarH * 0.5f; + float outerPad = 4.0f; + float innerX = tabAnimX + outerPad; + float innerY = y + outerPad; + float innerW = Math.max(1.0f, tabAnimW - outerPad * 2.0f); + float innerH = Math.max(1.0f, tabBarH - outerPad * 2.0f); + float innerRadius = innerH * 0.5f; + + int outerLeft = scaleAlpha(mixColor(theme().surface(), theme().windowBg(), 0.34f), eased); + int outerRight = scaleAlpha(mixColor(theme().surfaceHover(), theme().windowBg(), 0.22f), eased); + int outerStrokeLeft = scaleAlpha(mixColor(theme().strokeSoft(), theme().accentSoft(), 0.18f), 0.72f * eased); + int outerStrokeRight = scaleAlpha(mixColor(theme().strokeSoft(), theme().textPrimary(), 0.08f), 0.58f * eased); + + int activeLeft = scaleAlpha(mixColor(theme().accentSoft(), theme().accent(), 0.18f), eased); + int activeRight = scaleAlpha(mixColor(theme().accent(), theme().textPrimary(), 0.08f), eased); + int activeStrokeLeft = scaleAlpha(mixColor(theme().accentSoft(), theme().textPrimary(), 0.20f), 0.88f * eased); + int activeStrokeRight = scaleAlpha(theme().accent(), 0.92f * eased); + + try (RenderWarpStack.Scope ignored = Renderer2D.pushPerspectiveWarp( + tabBarX, + y, + tabBarW, + tabBarH, + 0.0f, + -5.0f * (1.0f - eased), + 0.0f, + 3.2f, + 0.80f, + 0.965f + 0.035f * eased + )) { + if (!islandShell) { + drawRoundedRectShadow( + tabBarX, + y, + tabBarW, + tabBarH, + outerRadius, + 10.0f, + 1.5f, + scaleAlpha(0xCC000000, 0.34f * eased) + ); + drawRoundedRectGradient(tabBarX, y, tabBarW, tabBarH, outerRadius, outerLeft, outerRight, 0.0f); + drawRoundedRectStrokeGradient(tabBarX, y, tabBarW, tabBarH, outerRadius, 1.0f, outerStrokeLeft, outerStrokeRight, 0.0f); + } + + drawRoundedRectGlow( + innerX, + innerY, + innerW, + innerH, + innerRadius, + 10.0f, + scaleAlpha(theme().accentSoft(), 0.30f * eased) + ); + drawRoundedRectGradient(innerX, innerY, innerW, innerH, innerRadius, activeLeft, activeRight, 0.0f); + drawRoundedRectStrokeGradient(innerX, innerY, innerW, innerH, innerRadius, 1.0f, activeStrokeLeft, activeStrokeRight, 0.0f); + + for (int i = 0; i < tabs.size(); i++) { + float x = tabX[i]; + float w = tabW[i]; + ClickGuiTabEntry tab = tabs.get(i); + boolean active = tab.id().equals(activeTabId); + boolean hovered = !closingForExit && mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + tabBarH; + if (i > 0) { + float sepH = tabBarH - outerPad * 3.4f; + float sepY = y + (tabBarH - sepH) * 0.5f; + drawRect(x, sepY, 1.0f, sepH, scaleAlpha(theme().strokeSoft(), 0.32f * eased)); + } + int textCol = active ? theme().textPrimary() : theme().textMuted(); + if (!active && hovered) { + textCol = mixColor(textCol, theme().textPrimary(), 0.55f); + } + textCol = scaleAlpha(textCol, eased); + float textH = textHeight(tabFont, TAB_FONT); + float textY = y + (tabBarH - textH) * 0.5f - 0.35f; + float labelW = textWidth(tabFont, tab.label(), TAB_FONT); + float textX = x + (w - labelW) * 0.5f; + drawText(tabFont, tab.label(), textX, textY, TAB_FONT, textCol, false); + } + } + } + + private static boolean handleTabClick(float mx, float my) { + if (!isInputReady()) return false; + if (mx < tabBarX || mx > tabBarX + tabBarW || my < tabBarY || my > tabBarY + tabBarH) return false; + List tabs = currentTabs(); + ensureActiveTab(tabs); + for (int i = 0; i < tabs.size(); i++) { + float x = tabX[i]; + float w = tabW[i]; + if (mx >= x && mx <= x + w && my >= tabBarY && my <= tabBarY + tabBarH) { + ClickGuiTabEntry tab = tabs.get(i); + if (!activeTabId.equals(tab.id())) { + ClickGuiSection previous = getActiveSection(); + activeTabId = tab.id(); + previous.onDeselected(); + ClickGuiSearch.deactivate(); + clearInlineText(); + closeTextEditor(false); + getActiveSection().onSelected(); + } + return true; + } + } + return false; + } + + private static ClickGuiSection getActiveSection() { + List tabs = currentTabs(); + ensureActiveTab(tabs); + for (ClickGuiTabEntry tab : tabs) { + if (tab.id().equals(activeTabId)) { + return tab.section(); + } + } + activeTabId = MODULES_TAB_ID; + return MODULES_SECTION; + } + + private static boolean isSettingsTabActive() { + return SETTINGS_TAB_ID.equals(activeTabId); + } + + private static List currentTabs() { + List addonSections = ClickGuiSectionManager.sections(); + ArrayList out = new ArrayList<>(2 + addonSections.size()); + out.add(new ClickGuiTabEntry(MODULES_TAB_ID, "Modules", MODULES_SECTION)); + out.add(new ClickGuiTabEntry(SETTINGS_TAB_ID, "Settings", SETTINGS_SECTION)); + for (ClickGuiSectionManager.Entry entry : addonSections) { + out.add(new ClickGuiTabEntry(entry.id(), entry.label(), entry.section())); + } + return out; + } + + private static void ensureActiveTab(List tabs) { + for (ClickGuiTabEntry tab : tabs) { + if (tab.id().equals(activeTabId)) { + return; + } + } + activeTabId = MODULES_TAB_ID; + } + + public static ClickGuiIslandState islandState() { + List tabs = currentTabs(); + ensureActiveTab(tabs); + String activeLabel = tabs.isEmpty() ? "" : tabs.get(0).label(); + for (ClickGuiTabEntry tab : tabs) { + if (tab.id().equals(activeTabId)) { + activeLabel = tab.label(); + break; + } + } + return new ClickGuiIslandState( + tabBarX, + tabBarY, + tabBarW, + tabBarH, + tabAnimX, + tabAnimW, + lifecycleAnim, + activeLabel, + tabs.size(), + picker != null || protocolHeuristicsEditor != null + ); + } + + private static void ensureTabCapacity(int size) { + if (tabX.length >= size && tabW.length >= size) return; + tabX = Arrays.copyOf(tabX, size); + tabW = Arrays.copyOf(tabW, size); + } + + private static float easeOutCubic(float t) { + t = Math.max(0.0f, Math.min(1.0f, t)); + float inv = 1.0f - t; + return 1.0f - inv * inv * inv; + } + + public static TextRenderer getInterRegular() { + if (interRegular == null) { + interRegular = Fonts.renderer("Inter", FontInfo.Type.Regular, TextRenderer.get()); + } + return interRegular; + } + + public static Renderer2D currentRenderer() { + return renderer; + } + + public static TextRenderer getInterMedium() { + if (interMedium == null) { + interMedium = Fonts.renderer("InterMedium", FontInfo.Type.Regular, getInterRegular()); + } + return interMedium; + } + + public static TextRenderer getIosevkaRegular() { + return getSfMedium(); + } + + public static TextRenderer getSfMedium() { + if (onestMedium == null) { + onestMedium = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + } + return onestMedium; + } + + public static TextRenderer getSfProDisplaySemibold() { + if (onestBold == null) { + onestBold = Fonts.renderer("OnestBold", FontInfo.Type.Regular, getInterMedium()); + } + return onestBold; + } + + public static TextRenderer getMonsterratRegular() { + if (monsterratRegular == null) { + monsterratRegular = Fonts.renderer("Monsterrat", FontInfo.Type.Regular, TextRenderer.get()); + } + return monsterratRegular; + } + + public static int framebufferWidth() { + return fbWidth; + } + + public static int framebufferHeight() { + return fbHeight; + } + + public static float getAnimAlpha() { + return 1f; + } + + public static float getMouseX() { + return mouseX; + } + + public static float getMouseY() { + return mouseY; + } + + public static int mixColor(int from, int to, float t) { + t = Math.max(0f, Math.min(1f, t)); + int a = (int) (((from >>> 24) & 0xFF) * (1 - t) + ((to >>> 24) & 0xFF) * t); + int r = (int) (((from >>> 16) & 0xFF) * (1 - t) + ((to >>> 16) & 0xFF) * t); + int g = (int) (((from >>> 8) & 0xFF) * (1 - t) + ((to >>> 8) & 0xFF) * t); + int b = (int) (((from) & 0xFF) * (1 - t) + ((to) & 0xFF) * t); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + public static int scaleAlpha(int color, float factor) { + int a = (color >>> 24) & 0xFF; + int na = (int) (a * Math.max(0f, Math.min(1f, factor))); + return (color & 0x00FFFFFF) | (na << 24); + } + + public static int withAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + public static float setRenderAlphaMultiplier(float alpha) { + float previous = renderAlphaMultiplier; + renderAlphaMultiplier = Math.max(0.0f, Math.min(1.0f, alpha)); + return previous; + } + + public static float getRenderAlphaMultiplier() { + return renderAlphaMultiplier; + } + + public static void restoreRenderAlphaMultiplier(float previous) { + renderAlphaMultiplier = Math.max(0.0f, Math.min(1.0f, previous)); + } + + private static int applyAlpha(int argb) { + if (renderAlphaMultiplier >= 0.999f) return argb; + int a = (argb >>> 24) & 0xFF; + int na = Math.round(a * renderAlphaMultiplier); + return (argb & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + public static float scaleForSize(float size) { + return size / 18.0f; + } + + public static float textWidth(TextRenderer tr, String text, float size) { + if (tr == null || text == null || text.isEmpty()) return 0f; + TextMeasureKey key = new TextMeasureKey(tr, Float.floatToIntBits(size), text); + Float cached = TEXT_WIDTH_CACHE.get(key); + if (cached != null) return cached; + float scale = scaleForSize(size); + tr.begin(scale, true, false); + try { + float width = (float) tr.getWidth(text, false); + TEXT_WIDTH_CACHE.put(key, width); + return width; + } finally { + tr.end(); + } + } + + public static float textHeight(TextRenderer tr, float size) { + if (tr == null) return 0f; + TextHeightKey key = new TextHeightKey(tr, Float.floatToIntBits(size)); + Float cached = TEXT_HEIGHT_CACHE.get(key); + if (cached != null) return cached; + float scale = scaleForSize(size); + tr.begin(scale, true, false); + try { + float height = (float) tr.getHeight(false); + TEXT_HEIGHT_CACHE.put(key, height); + return height; + } finally { + tr.end(); + } + } + + public static void beginTextBatch() { + if (textBatchActive) return; + textBatchActive = true; + TEXT_BATCH.clear(); + } + + public static void endTextBatch() { + if (!textBatchActive) return; + textBatchActive = false; + if (TEXT_BATCH.isEmpty()) return; + + java.util.LinkedHashMap> groups = new java.util.LinkedHashMap<>(); + for (TextBatchItem item : TEXT_BATCH) { + TextBatchKey key = new TextBatchKey(item.renderer, Float.floatToIntBits(item.size), item.shadow); + java.util.ArrayList list = groups.get(key); + if (list == null) { + list = new java.util.ArrayList<>(); + groups.put(key, list); + } + list.add(item); + } + + for (var entry : groups.entrySet()) { + TextBatchKey key = entry.getKey(); + TextRenderer tr = key.renderer; + if (tr == null) continue; + float size = Float.intBitsToFloat(key.sizeBits); + float scale = scaleForSize(size); + tr.begin(scale, false, false); + for (TextBatchItem item : entry.getValue()) { + tr.render(item.text, item.x, item.y, new RenderColor(item.argb), item.shadow); + } + tr.end(); + } + + TEXT_BATCH.clear(); + } + + public static void drawText(TextRenderer tr, String text, float x, float y, float size, int argb, boolean shadow) { + if (tr == null || text == null || text.isEmpty()) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + if (textBatchActive) { + TEXT_BATCH.add(new TextBatchItem(tr, text, x, y, size, argb, shadow)); + return; + } + float scale = scaleForSize(size); + tr.begin(scale, false, false); + tr.render(text, x, y, new RenderColor(argb), shadow); + tr.end(); + } + + public static String fitText(TextRenderer tr, String text, float size, float maxWidth) { + if (text == null) return ""; + if (maxWidth <= 0f) return text; + String trimmed = text; + float w = textWidth(tr, trimmed, size); + if (w <= maxWidth) return trimmed; + while (trimmed.length() > 1 && textWidth(tr, trimmed + "...", size) > maxWidth) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed + "..."; + } + + public static List wrapText(TextRenderer tr, String text, float size, float maxWidth, int maxLines) { + ArrayList lines = new ArrayList<>(); + if (text == null || text.isBlank()) { + lines.add(""); + return lines; + } + if (maxLines <= 1 || maxWidth <= 0f) { + lines.add(fitText(tr, text, size, maxWidth)); + return lines; + } + + String remaining = normalizeSpaces(text); + while (!remaining.isEmpty() && lines.size() < maxLines) { + if (lines.size() == maxLines - 1) { + lines.add(fitText(tr, remaining, size, maxWidth)); + break; + } + if (textWidth(tr, remaining, size) <= maxWidth) { + lines.add(remaining); + break; + } + + int split = bestWrapIndex(tr, remaining, size, maxWidth); + String line = remaining.substring(0, split).trim(); + if (line.isEmpty()) { + lines.add(fitText(tr, remaining, size, maxWidth)); + break; + } + lines.add(line); + remaining = remaining.substring(split).trim(); + } + + if (lines.isEmpty()) { + lines.add(fitText(tr, text, size, maxWidth)); + } + return lines; + } + + private static int bestWrapIndex(TextRenderer tr, String text, float size, float maxWidth) { + int bestSpace = -1; + for (int i = 0; i < text.length(); i++) { + if (!Character.isWhitespace(text.charAt(i))) continue; + String candidate = text.substring(0, i).trim(); + if (!candidate.isEmpty() && textWidth(tr, candidate, size) <= maxWidth) { + bestSpace = i; + continue; + } + if (bestSpace >= 0) break; + } + if (bestSpace >= 0) return bestSpace; + + String fitted = fitText(tr, text, size, maxWidth); + if (fitted.endsWith("...")) { + fitted = fitted.substring(0, Math.max(1, fitted.length() - 3)); + } + return Math.max(1, Math.min(text.length(), fitted.length())); + } + + private static String normalizeSpaces(String text) { + if (text == null) return ""; + String trimmed = text.trim(); + if (trimmed.isEmpty()) return ""; + + StringBuilder sb = new StringBuilder(trimmed.length()); + boolean lastSpace = false; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (Character.isWhitespace(c)) { + if (!lastSpace) { + sb.append(' '); + lastSpace = true; + } + } else { + sb.append(c); + lastSpace = false; + } + } + return sb.toString(); + } + + private static String normalizeInlineText(String text) { + if (text == null) return ""; + String normalized = text.replace("\r\n", "\n").replace('\r', '\n'); + return normalized.replace('\n', ' '); + } + + public static void drawRoundedRect(float x, float y, float w, float h, float radius, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRect(x, y, w, h, radius, 1.1f, argb); + } + + public static void drawRoundedRectStroke(float x, float y, float w, float h, float radius, float thickness, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectStroke(x, y, w, h, radius, 1.1f, thickness, argb); + } + + public static void drawRoundedRectGlow(float x, float y, float w, float h, + float radius, float glow, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectGlow(x, y, w, h, radius, 1.1f, glow, argb); + } + + public static void drawRoundedRectShadow(float x, float y, float w, float h, + float radius, float softness, float spread, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectShadow(x, y, w, h, radius, softness, spread, argb); + } + + public static void drawRoundedRectGradient(float x, float y, float w, float h, float radius, + int startArgb, int endArgb, float angleDeg) { + if (renderer == null) return; + startArgb = applyAlpha(startArgb); + endArgb = applyAlpha(endArgb); + if (((startArgb >>> 24) & 0xFF) <= 0 && ((endArgb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectGradient(x, y, w, h, radius, 1.1f, startArgb, endArgb, angleDeg); + } + + public static void drawRoundedRectStrokeGradient(float x, float y, float w, float h, float radius, float thickness, + int startArgb, int endArgb, float angleDeg) { + if (renderer == null) return; + startArgb = applyAlpha(startArgb); + endArgb = applyAlpha(endArgb); + if (((startArgb >>> 24) & 0xFF) <= 0 && ((endArgb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectStrokeGradient(x, y, w, h, radius, 1.1f, thickness, startArgb, endArgb, angleDeg); + } + + public static void drawRoundedRectCorners(float x, float y, float w, float h, + float radiusTL, float radiusTR, float radiusBR, float radiusBL, + int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.roundedRectCorners(x, y, w, h, radiusTL, radiusTR, radiusBR, radiusBL, 1.1f, argb); + } + + public static void drawRect(float x, float y, float w, float h, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.quad(x, y, w, h, argb); + } + + public static void flushRenderer() { + if (renderer == null) return; + if (Renderer2D.isBatching()) { + Renderer2D.flushBatch(); + } else { + renderer.begin(); + } + } + + public static void drawGradientRect(float x, float y, float w, float h, + int cTopLeft, int cTopRight, int cBottomRight, int cBottomLeft) { + if (renderer == null) return; + cTopLeft = applyAlpha(cTopLeft); + cTopRight = applyAlpha(cTopRight); + cBottomRight = applyAlpha(cBottomRight); + cBottomLeft = applyAlpha(cBottomLeft); + renderer.quadGradient(x, y, w, h, cTopLeft, cTopRight, cBottomRight, cBottomLeft); + } + + public static void drawCircle(float cx, float cy, float r, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.circle(cx, cy, r, argb); + } + + public static void drawLine(float x1, float y1, float x2, float y2, int argb) { + if (renderer == null) return; + argb = applyAlpha(argb); + if (((argb >>> 24) & 0xFF) <= 0) return; + renderer.line(x1, y1, x2, y2, argb); + } + + public static void drawBlur(float x, float y, float w, float h, float radius, int argb, float alpha) { + alpha *= renderAlphaMultiplier; + alpha = AnimationUtility.clamp(alpha * clickGuiBlurDensityScale(), 0.0f, 1.0f); + if (alpha <= 0.01f) return; + float quality = clickGuiBlurQuality(); + float brightness = 1.0f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, brightness, alpha, 0xFFFFFF); + } + + public static void drawGlass(float x, float y, float w, float h, float radius, float alpha) { + if (renderer == null) return; + alpha *= renderAlphaMultiplier; + if (alpha <= 0.01f) return; + + float pad = 5f; + float gx = x - pad; + float gy = y - pad; + float gw = w + pad * 2f; + float gh = h + pad * 2f; + float gr = Math.max(0f, radius + pad); + float blurAlpha = AnimationUtility.clamp(alpha * clickGuiGlassBlurScale(), 0.0f, 1.0f); + float scale = Math.max(0.75f, gr / 10.0f); + + Renderer2D.COLOR.blurRect(gx, gy, gw, gh, gr, clickGuiBlurQuality(), 1.0f, blurAlpha, 0xFFFFFF); + + renderer.liquidGlassRect( + gx, gy, gw, gh, gr, + 15.0f * scale, + 0xFFFFFFFF, + alpha, + blurAlpha, + -18.0f, + 1.0f, + 0.78f, + 0.52f, + 0.190f * alpha, + 0.0f + ); + } + + public static float clickGuiBlurQuality() { + return 27.35f; + } + + public static float clickGuiBlurDensityScale() { + return 1.16f; + } + + public static float clickGuiGlassBlurScale() { + return 1.28f; + } + + public static int getColorClipboard() { + return colorClipboard; + } + + public static void setColorClipboard(int argb) { + colorClipboard = argb; + } + + public static void beginKeyBind(Object owner) { + clearInlineText(); + waitingForKey = true; + keyBindOwner = owner; + pendingBindKeys.clear(); + ClickGuiSearch.deactivate(); + ClickGuiSounds.bindingStart(); + } + + public static void finishKeyBind() { + waitingForKey = false; + keyBindOwner = null; + pendingBindKeys.clear(); + } + + private static void addPendingKey(int keyCode, int scancode) { + int code = KeyUtil.fromKeyInput(keyCode, scancode); + if (code == KeyUtil.NONE) return; + pendingBindKeys.add(code); + } + + private static void addPendingMouseButton(int button) { + if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return; + pendingBindKeys.add(KeyUtil.mouseButtonToCode(button)); + } + + private static void submitKeySelection(Set keys) { + if (keys == null || keys.isEmpty()) { + submitKeySelection((String) null); + return; + } + submitKeySelection(KeyUtil.comboToString(keys)); + } + + private static void submitKeySelection(String combo) { + if (keyBindOwner instanceof KeyBindSetting ks) { + ks.onKeySelected(combo); + } else if (keyBindOwner instanceof FunctionBindSetting fs) { + fs.onKeySelected(combo); + } + } + + public static String getPendingBindDisplay() { + if (!waitingForKey) return null; + return pendingBindKeys.isEmpty() ? "..." : KeyUtil.comboToString(pendingBindKeys); + } + + public static void openTextEditor(TextEditorOwner owner, String title, String seedText, float anchorX, float anchorY, float anchorW) { + if (owner == null) return; + clearInlineText(); + if (seedText == null) seedText = ""; + ClickGuiSearch.deactivate(); + textEditor = new ClickGuiTextEditorState(owner, title, seedText, anchorX, anchorY, anchorW); + } + + public static void closeTextEditor(boolean save) { + if (textEditor == null) return; + if (save) textEditor.save(); + textEditor = null; + } + + public static void renderTextEditor(int fbw, int fbh) { + if (textEditor == null) return; + textEditor.render(fbw, fbh); + } + + private static void renderPicker(int fbw, int fbh) { + if (picker == null) return; + picker.render(fbw, fbh); + } + + public static void toggleInlineText(TextSetting owner) { + if (owner == null) return; + if (inlineTextOwner == owner) { + clearInlineText(); + return; + } + inlineTextOwner = owner; + inlineTextBuffer = new StringBuilder(owner.getEditorText()); + ClickGuiSearch.deactivate(); + } + + public static boolean isInlineTextActive() { + return inlineTextOwner != null; + } + + public static boolean isInlineTextOwner(TextSetting owner) { + return inlineTextOwner == owner; + } + + public static String getInlineText(TextSetting owner) { + if (owner == null) return ""; + if (inlineTextOwner == owner && inlineTextBuffer != null) { + return inlineTextBuffer.toString(); + } + return owner.getEditorText(); + } + + public static void updateInlineTextBounds(TextSetting owner, float x, float y, float w, float h) { + if (inlineTextOwner != owner) return; + inlineTextX = x; + inlineTextY = y; + inlineTextW = w; + inlineTextH = h; + } + + private static void finishInlineText(boolean save) { + if (inlineTextOwner == null || inlineTextBuffer == null) { + clearInlineText(); + return; + } + if (save) { + inlineTextOwner.applyEditorText(inlineTextBuffer.toString()); + } + clearInlineText(); + } + + private static void clearInlineText() { + inlineTextOwner = null; + inlineTextBuffer = null; + inlineTextX = inlineTextY = inlineTextW = inlineTextH = 0f; + } + + private static void inlineBackspace() { + if (inlineTextBuffer == null) return; + int len = inlineTextBuffer.length(); + if (len <= 0) return; + inlineTextBuffer.deleteCharAt(len - 1); + } + + private static void inlinePaste() { + if (inlineTextBuffer == null) return; + String clip = ClipboardUtil.get(); + if (clip == null || clip.isEmpty()) return; + inlineTextBuffer.append(normalizeInlineText(clip)); + } + + public static boolean isTextEditorActive() { + return textEditor != null; + } + + public static boolean isPickerActive() { + return picker != null || protocolHeuristicsEditor != null; + } + + public static boolean isBlockingModuleKeybinds() { + if (!ModuleManager.isEnabled("clickgui")) return false; + if (waitingForKey) return true; + if (textEditor != null) return true; + if (inlineTextOwner != null) return true; + if (protocolHeuristicsEditor != null) return true; + if (picker != null && picker.isListening()) return true; + return ClickGuiSearch.isActive(); + } + + public static void processPickerIcons(GuiGraphicsExtractor ctx) { + if (pickerIcons.isEmpty()) { + pickerIconScissorActive = false; + return; + } + if (ctx == null) { + pickerIcons.clear(); + pickerIconScissorActive = false; + return; + } + if (!(ctx instanceof IGuiGraphics accessor)) { + pickerIcons.clear(); + pickerIconScissorActive = false; + return; + } + Minecraft mc = MC; + if (mc == null) { + pickerIcons.clear(); + pickerIconScissorActive = false; + return; + } + int seed = 0; + for (PickerIcon icon : pickerIcons) { + ItemStack stack = icon.stack(); + if (stack == null || stack.isEmpty()) continue; + boolean clippedGlobal = false; + boolean clippedLocal = false; + if (icon.hasGlobalClip()) { + clippedGlobal = ScissorFunction.pushRaw( + icon.globalClipX(), + icon.globalClipY(), + icon.globalClipW(), + icon.globalClipH() + ); + } + if (icon.hasClip()) { + clippedLocal = ScissorFunction.pushRaw( + icon.clipX(), + icon.clipY(), + icon.clipW(), + icon.clipH() + ); + } + Renderer2D.COLOR.begin(); + // Picker screen uses beginUnscaledLogical(), so queued icon coordinates are already logical. + Renderer2D.COLOR.item( + stack, + icon.x(), + icon.y(), + icon.scale(), + seed++, + Renderer2D.ITEM_OVERLAY_ALL, + null + ); + Renderer2D.COLOR.render(); + if (clippedLocal) ScissorFunction.pop(); + if (clippedGlobal) ScissorFunction.pop(); + } + pickerIcons.clear(); + pickerIconScissorActive = false; + } + + public static void beginPickerIconScissor(float x, float y, float w, float h) { + pickerIconScissorActive = true; + pickerIconScissorX = x; + pickerIconScissorY = y; + pickerIconScissorW = w; + pickerIconScissorH = h; + } + + public static void endPickerIconScissor() { + pickerIconScissorActive = false; + } + + public static void queuePickerIcon(ItemStack stack, float x, float y, float scale) { + if (stack == null || stack.isEmpty()) return; + pickerIcons.add(new PickerIcon( + stack, + x, + y, + scale, + currentPickerIconScissorActive(), + pickerIconScissorX, + pickerIconScissorY, + pickerIconScissorW, + pickerIconScissorH, + false, + 0f, + 0f, + 0f, + 0f + )); + } + + public static void queuePickerIcon(ItemStack stack, + float x, + float y, + float scale, + float clipX, + float clipY, + float clipW, + float clipH) { + if (stack == null || stack.isEmpty()) return; + boolean hasClip = clipW > 0f && clipH > 0f; + pickerIcons.add(new PickerIcon( + stack, + x, + y, + scale, + currentPickerIconScissorActive(), + pickerIconScissorX, + pickerIconScissorY, + pickerIconScissorW, + pickerIconScissorH, + hasClip, + clipX, + clipY, + clipW, + clipH + )); + } + + private static boolean currentPickerIconScissorActive() { + return pickerIconScissorActive && pickerIconScissorW > 0f && pickerIconScissorH > 0f; + } + + public static void openClickGuiScreen() { + if (MC == null) return; + if (ClientScreen.current() != null) return; + if (mainScreen == null) { + mainScreen = new ClickGuiScreen(); + } + ClickGuiSounds.guiOpen(); + ClientScreen.show(MC, mainScreen); + } + + public static void captureMainScreen() { + if (MC != null && ClientScreen.current() instanceof ClickGuiScreen cs) { + mainScreen = cs; + } + } + + public static ClickGuiScreen ensureMainScreen() { + if (mainScreen == null) { + mainScreen = new ClickGuiScreen(); + } + return mainScreen; + } + + private static void openPickerScreen() { + if (MC == null) return; + if (ClientScreen.current() instanceof ClickGuiScreen cs) { + mainScreen = cs; + } + if (pickerScreen == null) { + pickerScreen = new ClickGuiPickerScreen(); + } + ClientScreen.show(MC, pickerScreen); + } + + public static void clearScreens() { + mainScreen = null; + pickerScreen = null; + SettingsTabRuntime.clearScreens(); + } + + public static void shutdownForRuntime() { + finishCloseImmediately(); + clearInlineText(); + clearPicker(); + textEditor = null; + pickerIcons.clear(); + pendingBindKeys.clear(); + waitingForKey = false; + keyBindOwner = null; + renderer = null; + init = false; + } + + public static void openPicker(TextListSetting owner, String title, TextListSetting.PickerMode mode) { + if (owner == null) return; + clearInlineText(); + ClickGuiSearch.deactivate(); + protocolHeuristicsEditor = null; + picker = new ClickGuiPickerState(owner, title, mode); + openPickerScreen(); + } + + public static void openProtocolHeuristicsEditor(ProtocolHeuristicsSetting owner, String title) { + if (owner == null) return; + clearInlineText(); + ClickGuiSearch.deactivate(); + picker = null; + protocolHeuristicsEditor = new CombatProtocolHeuristicsEditorState(owner, title); + openPickerScreen(); + } + + public static void closeProtocolHeuristicsEditor() { + closePicker(); + } + + public static void closePickerScreen() { + closePicker(); + } + + private static void closePicker() { + picker = null; + protocolHeuristicsEditor = null; + textEditor = null; + if (MC != null && ClientScreen.current() instanceof ClickGuiPickerScreen) { + if (mainScreen == null) { + mainScreen = new ClickGuiScreen(); + } + ClientScreen.show(MC, mainScreen); + } + } + + private static void clearPicker() { + picker = null; + protocolHeuristicsEditor = null; + } + + private static synchronized void requestConfigSave() { + Runnable task = () -> { + try { + ConfigSerializer.saveAll(); + } catch (Throwable ignored) { + } + }; + + if (pendingSaveTask == null || pendingSaveTask.isDone()) { + pendingSaveTask = CompletableFuture.runAsync(task); + } else { + pendingSaveTask = pendingSaveTask.thenRunAsync(task); + } + } + + private record ClickGuiTabEntry(String id, String label, ClickGuiSection section) { + } + + public record ClickGuiIslandState(float tabBarX, + float tabBarY, + float tabBarW, + float tabBarH, + float activeX, + float activeW, + float lifecycle, + String activeLabel, + int tabCount, + boolean pickerActive) { + } + + private record TextBatchItem(TextRenderer renderer, String text, float x, float y, float size, int argb, + boolean shadow) { + } + + private record TextBatchKey(TextRenderer renderer, int sizeBits, boolean shadow) { + } + + private record TextMeasureKey(TextRenderer renderer, int sizeBits, String text) { + } + + private record TextHeightKey(TextRenderer renderer, int sizeBits) { + } + + private record PickerIcon(ItemStack stack, + float x, + float y, + float scale, + boolean hasGlobalClip, + float globalClipX, + float globalClipY, + float globalClipW, + float globalClipH, + boolean hasClip, + float clipX, + float clipY, + float clipW, + float clipH) { + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiScreen.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiScreen.java new file mode 100644 index 0000000..e8643c6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiScreen.java @@ -0,0 +1,75 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import dev.loki.lovisual.features.module.ModuleManager; + +import java.nio.file.Path; +import java.util.List; + +public final class ClickGuiScreen extends Screen { + + public ClickGuiScreen() { + super(Component.literal("ClickGui")); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // Render is intentionally deferred to GameRendererMixin after GuiRenderer.render(...). + // Vanilla HUD/screen elements are queued into GuiRenderState and otherwise flush above immediate ClickGui rendering. + } + + @Override + public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + // No vanilla blur/darkening behind ClickGUI. + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + ClickGuiRenderer.onMouseMoveScaled(mouseX, mouseY); + } + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + ClickGuiRenderer.onMouseButtonScaled(click.x(), click.y(), click.button(), true); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + ClickGuiRenderer.onMouseButtonScaled(click.x(), click.y(), click.button(), false); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + ClickGuiRenderer.onMouseScrollScaled(mouseX, mouseY, verticalAmount); + return true; + } + + @Override + public void onFilesDrop(List paths) { + if (!ClickGuiRenderer.onFilesDrop(paths)) { + super.onFilesDrop(paths); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + ModuleManager.setEnabled("clickgui", false); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiSearch.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiSearch.java new file mode 100644 index 0000000..1c32484 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiSearch.java @@ -0,0 +1,84 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui; + +import java.util.Locale; + +public enum ClickGuiSearch { + ; + + private static boolean active = false; + private static String text = ""; + private static String textLower = ""; + + public static boolean isActive() { + return active; + } + + public static void setActive(boolean v) { + active = v; + if (!v) { + text = ""; + textLower = ""; + } + } + + public static void deactivate() { + active = false; + text = ""; + textLower = ""; + } + + public static String getText() { + return text; + } + + public static void append(char c) { + if (c < 32) return; + if (text.length() > 32) return; + text += c; + textLower = text.toLowerCase(Locale.ROOT); + } + + public static void backspace() { + if (text.isEmpty()) return; + text = text.substring(0, text.length() - 1); + textLower = text.toLowerCase(); + } + + public static boolean matches(String moduleName) { + if (text.isEmpty()) return true; + return matchesCandidate(moduleName); + } + + public static boolean matches(String moduleName, Iterable aliases) { + if (text.isEmpty()) return true; + if (matchesCandidate(moduleName)) return true; + if (aliases == null) return false; + + for (String alias : aliases) { + if (matchesCandidate(alias)) return true; + } + return false; + } + + private static boolean matchesCandidate(String candidate) { + if (candidate == null || candidate.isBlank()) return false; + + String normalized = candidate.toLowerCase(Locale.ROOT); + String compactCandidate = compact(normalized); + String compactQuery = compact(textLower); + + return normalized.contains(textLower) + || compactCandidate.contains(compactQuery); + } + + private static String compact(String value) { + return value == null ? "" : value.replace(" ", "").replace("_", "").replace("-", ""); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/editor/ClickGuiTextEditorState.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/editor/ClickGuiTextEditorState.java new file mode 100644 index 0000000..462d6e2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/editor/ClickGuiTextEditorState.java @@ -0,0 +1,538 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.editor; + +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.Minecraft; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.settings.TextEditorOwner; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.util.text.TextSelection; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +public final class ClickGuiTextEditorState { + private static final float WIDTH = 360f; + private static final float HEIGHT = 240f; + private static final float PADDING = 14f; + private static final float TEXT_PADDING = 8f; + private static final float BUTTON_W = 70f; + private static final float BUTTON_H = 24f; + private static final float LINE_H = 18f; + + private final TextEditorOwner owner; + private final String title; + private final StringBuilder buffer; + private final boolean singleLine; + private final float anchorX; + private final float anchorY; + private final float anchorW; + private final TextSelection selection = new TextSelection(); + private int caret; + private float modalX, modalY; + private float textX, textY, textW, textH; + private float saveX, saveY, saveW, saveH; + private float cancelX, cancelY, cancelW, cancelH; + private float scroll; + private float scrollX; + private int preferredColumn = -1; + private boolean dragging = false; + + public ClickGuiTextEditorState(TextEditorOwner owner, String title, String seed, float anchorX, float anchorY, float anchorW) { + this.owner = owner; + this.title = title == null ? "Edit list" : title; + this.singleLine = owner != null && owner.isSingleLine(); + String normalizedSeed = singleLine ? normalizeSingleLine(seed) : seed; + this.buffer = new StringBuilder(normalizedSeed == null ? "" : normalizedSeed); + this.caret = buffer.length(); + this.anchorX = anchorX; + this.anchorY = anchorY; + this.anchorW = anchorW; + this.scroll = 0f; + this.scrollX = 0f; + } + + private static boolean inside(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static float clamp(float v, float min, float max) { + return Math.max(min, Math.min(max, v)); + } + + private static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(max, v)); + } + + private static String normalizeSingleLine(String text) { + if (text == null) return ""; + String normalized = text.replace("\r\n", "\n").replace('\r', '\n'); + return normalized.replace('\n', ' '); + } + + private static int[] caretLineAndColumn(String[] lines, int caretIndex) { + int remaining = caretIndex; + for (int i = 0; i < lines.length; i++) { + int len = lines[i].length(); + if (remaining <= len) { + return new int[]{i, remaining}; + } + remaining -= len; + if (i < lines.length - 1) remaining -= 1; + } + return new int[]{Math.max(0, lines.length - 1), lines.length == 0 ? 0 : lines[lines.length - 1].length()}; + } + + private static String safeSubstring(String s, int from, int to) { + int start = Math.max(0, Math.min(s.length(), from)); + int end = Math.max(start, Math.min(s.length(), to)); + return s.substring(start, end); + } + + public void handleMouseDown(float mx, float my, int button) { + if (button != 0) return; + + layout(ClickGuiRenderer.framebufferWidth(), ClickGuiRenderer.framebufferHeight()); + + if (!inside(mx, my, modalX, modalY, WIDTH, HEIGHT)) { + ClickGuiRenderer.closeTextEditor(false); + return; + } + + if (inside(mx, my, saveX, saveY, saveW, saveH)) { + ClickGuiRenderer.closeTextEditor(true); + return; + } + + if (inside(mx, my, cancelX, cancelY, cancelW, cancelH)) { + ClickGuiRenderer.closeTextEditor(false); + return; + } + + if (inside(mx, my, textX, textY, textW, textH)) { + int target = caretFromPoint(mx, my); + if (isShiftDown()) { + if (!selection.appliesToLine(0)) selection.update(0, caret); + selection.updateCaret(target); + } else { + selection.clear(); + } + caret = target; + preferredColumn = -1; + ensureCaretVisible(); + dragging = true; + return; + } + + selection.clear(); + } + + public void handleMouseUp(float mx, float my, int button) { + if (button == 0) { + dragging = false; + } + } + + public void handleMouseMove(float mx, float my) { + if (!dragging) return; + int target = caretFromPoint(mx, my); + selection.update(0, target); + caret = target; + preferredColumn = -1; + ensureCaretVisible(); + } + + public void insertChar(char c) { + if (Character.isISOControl(c)) return; + if (deleteSelection()) { + } + buffer.insert(caret, c); + caret++; + selection.clear(); + preferredColumn = -1; + ensureCaretVisible(); + dragging = false; + } + + public void insertNewline() { + if (singleLine) return; + if (deleteSelection()) { + } + buffer.insert(caret, '\n'); + caret++; + selection.clear(); + preferredColumn = -1; + ensureCaretVisible(); + dragging = false; + } + + public void pasteFromClipboard() { + String clip = ClipboardUtil.get(); + if (clip == null || clip.isEmpty()) return; + String normalized = clip.replace("\r\n", "\n").replace('\r', '\n'); + if (singleLine) normalized = normalizeSingleLine(normalized); + insertText(normalized); + } + + public void backspace() { + if (deleteSelection()) { + preferredColumn = -1; + ensureCaretVisible(); + return; + } + if (caret == 0) return; + buffer.deleteCharAt(caret - 1); + caret--; + selection.clear(); + preferredColumn = -1; + ensureCaretVisible(); + dragging = false; + } + + public void deleteForward() { + if (deleteSelection()) { + preferredColumn = -1; + ensureCaretVisible(); + return; + } + if (caret >= buffer.length()) return; + buffer.deleteCharAt(caret); + selection.clear(); + preferredColumn = -1; + ensureCaretVisible(); + dragging = false; + } + + public void moveCaret(int delta, boolean extend) { + int target = clamp(caret + delta, 0, buffer.length()); + updateSelectionForMove(target, extend); + caret = target; + preferredColumn = -1; + ensureCaretVisible(); + } + + public void moveCaretToStart(boolean extend) { + updateSelectionForMove(0, extend); + caret = 0; + preferredColumn = -1; + ensureCaretVisible(); + } + + public void moveCaretToEnd(boolean extend) { + int end = buffer.length(); + updateSelectionForMove(end, extend); + caret = end; + preferredColumn = -1; + ensureCaretVisible(); + } + + public void moveCaretVertical(int dir, boolean extend) { + String[] lines = buffer.toString().split("\\n", -1); + int[] pos = caretLineAndColumn(lines, caret); + int currentCol = pos[1]; + if (preferredColumn < 0) preferredColumn = currentCol; + int targetLine = clamp(pos[0] + dir, 0, Math.max(0, lines.length - 1)); + int targetCol = Math.min(preferredColumn, lines[targetLine].length()); + int targetIndex = indexForLineColumn(lines, targetLine, targetCol); + updateSelectionForMove(targetIndex, extend); + caret = targetIndex; + ensureCaretVisible(); + } + + public void copySelection() { + if (selection.appliesToLine(0) && selection.hasRange()) { + int start = Math.min(selection.start(), selection.end()); + int end = Math.max(selection.start(), selection.end()) + 1; + start = clamp(start, 0, buffer.length()); + end = clamp(end, 0, buffer.length()); + if (end > start) { + ClipboardUtil.copy(buffer.substring(start, end)); + return; + } + } + if (buffer.length() > 0) { + ClipboardUtil.copy(buffer.toString()); + } + } + + public void selectAll() { + selection.update(0, 0); + selection.updateCaret(buffer.length()); + caret = buffer.length(); + preferredColumn = -1; + ensureCaretVisible(); + } + + public void save() { + owner.applyEditorText(buffer.toString()); + } + + public void scroll(double delta) { + String[] lines = buffer.toString().split("\\n", -1); + float visible = textH - TEXT_PADDING * 2f; + float content = lines.length * LINE_H; + float minScroll = Math.min(0f, visible - content); + scroll += (float) (delta * LINE_H * 1.5f); + scroll = clamp(scroll, minScroll, 0f); + } + + public void render(int fbw, int fbh) { + layout(fbw, fbh); + + ClickGuiRenderer.drawRoundedRect(modalX, modalY, WIDTH, HEIGHT, 12f, theme().windowBg()); + ClickGuiRenderer.drawRoundedRectStroke(modalX, modalY, WIDTH, HEIGHT, 12f, 1.2f, theme().windowStroke()); + + TextRenderer titleFont = ClickGuiRenderer.getIosevkaRegular(); + float titleSize = 16f; + ClickGuiRenderer.drawText(titleFont, title, modalX + PADDING, modalY + 12f, titleSize, theme().textPrimary(), false); + if (!singleLine) { + ClickGuiRenderer.drawText(ClickGuiRenderer.getIosevkaRegular(), "One entry per line", modalX + PADDING, modalY + 30f, 12f, theme().textMuted(), false); + } + + ClickGuiRenderer.drawRoundedRect(textX, textY, textW, textH, 8f, theme().surface()); + ClickGuiRenderer.drawRoundedRectStroke(textX, textY, textW, textH, 8f, 1.0f, theme().strokeSoft()); + + String[] lines = buffer.toString().split("\\n", -1); + TextRenderer textFont = ClickGuiRenderer.getIosevkaRegular(); + float fontSize = 15f; + float viewX = textX + TEXT_PADDING - scrollX; + float viewY = textY + TEXT_PADDING + scroll; + float lineTop = viewY; + + float scissorX = textX + TEXT_PADDING; + float scissorY = textY + TEXT_PADDING; + float scissorW = textW - TEXT_PADDING * 2f; + float scissorH = textH - TEXT_PADDING * 2f; + + boolean clipped = ScissorFunction.pushRaw(scissorX, scissorY, scissorW, scissorH); + int globalOffset = 0; + for (int idx = 0; idx < lines.length; idx++) { + String line = lines[idx]; + if (selection.appliesToLine(0) && selection.hasRange()) { + int selStart = Math.min(selection.start(), selection.end()); + int selEnd = Math.max(selection.start(), selection.end()) + 1; + int selA = Math.max(globalOffset, selStart); + int selB = Math.min(globalOffset + line.length(), selEnd); + if (selB > selA) { + int colA = selA - globalOffset; + int colB = selB - globalOffset; + float x0 = viewX + ClickGuiRenderer.textWidth(textFont, safeSubstring(line, 0, colA), fontSize); + float x1 = viewX + ClickGuiRenderer.textWidth(textFont, safeSubstring(line, 0, colB), fontSize); + float y0 = lineTop; + ClickGuiRenderer.drawRoundedRect(x0, y0, Math.max(1f, x1 - x0), LINE_H, 3f, theme().accentSoft()); + } + } + float textY = lineTop + (LINE_H - ClickGuiRenderer.textHeight(textFont, fontSize)) * 0.5f; + ClickGuiRenderer.drawText(textFont, line, viewX, textY, fontSize, theme().textPrimary(), false); + lineTop += LINE_H; + globalOffset += line.length(); + if (idx < lines.length - 1) globalOffset++; + } + + int[] caretPos = caretLineAndColumn(lines, caret); + float caretLineTop = viewY + caretPos[0] * LINE_H; + String caretLine = caretPos[0] < lines.length ? lines[caretPos[0]] : ""; + float caretOffsetX = ClickGuiRenderer.textWidth(textFont, safeSubstring(caretLine, 0, caretPos[1]), fontSize); + float cx = viewX + caretOffsetX; + float cy0 = caretLineTop + 2f; + float cy1 = caretLineTop + LINE_H - 2f; + if (AnimationUtility.blink(500L)) { + ClickGuiRenderer.drawLine(cx, cy0, cx, cy1, theme().textPrimary()); + } + if (clipped) ScissorFunction.pop(); + + float contentHeight = lines.length * LINE_H; + float visibleHeight = scissorH; + if (contentHeight > visibleHeight + 0.5f) { + float trackX = scissorX + scissorW - 6f; + float trackY = scissorY; + float trackW = 4f; + float trackH = scissorH; + + float thumbH = Math.max(18f, trackH * (visibleHeight / contentHeight)); + float maxScroll = contentHeight - visibleHeight; + float scrollNorm = Math.min(1f, Math.max(0f, -scroll / maxScroll)); + float thumbY = trackY + (trackH - thumbH) * scrollNorm; + + ClickGuiRenderer.drawRoundedRect(trackX, trackY, trackW, trackH, 3f, theme().surface()); + ClickGuiRenderer.drawRoundedRect(trackX, thumbY, trackW, thumbH, 3f, theme().strokeSoft()); + } + + int saveBg = theme().accent(); + int cancelBg = theme().surface(); + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + boolean hoverCancel = inside(mx, my, cancelX, cancelY, cancelW, cancelH); + boolean hoverSave = inside(mx, my, saveX, saveY, saveW, saveH); + + int cancelFill = hoverCancel ? theme().surfaceHover() : cancelBg; + int saveFill = hoverSave ? ClickGuiRenderer.mixColor(saveBg, theme().surfaceHover(), 0.35f) : saveBg; + + ClickGuiRenderer.drawRoundedRect(cancelX, cancelY, cancelW, cancelH, 8f, cancelFill); + ClickGuiRenderer.drawRoundedRectStroke(cancelX, cancelY, cancelW, cancelH, 8f, 1.05f, theme().strokeSoft()); + float cancelTextY = cancelY + (cancelH - ClickGuiRenderer.textHeight(textFont, 14f)) * 0.5f; + ClickGuiRenderer.drawText(textFont, "Cancel", cancelX + 14f, cancelTextY, 14f, theme().textPrimary(), false); + + ClickGuiRenderer.drawRoundedRect(saveX, saveY, saveW, saveH, 8f, saveFill); + ClickGuiRenderer.drawRoundedRectStroke(saveX, saveY, saveW, saveH, 8f, 1.05f, theme().strokeSoft()); + float saveTextY = saveY + (saveH - ClickGuiRenderer.textHeight(textFont, 14f)) * 0.5f; + ClickGuiRenderer.drawText(textFont, "Save", saveX + 20f, saveTextY, 14f, theme().textPrimary(), false); + } + + private void layout(int fbw, int fbh) { + float margin = 12f; + modalX = clamp(anchorX - WIDTH + anchorW + margin, margin, fbw - WIDTH - margin); + modalY = clamp(anchorY - HEIGHT / 2f, margin, fbh - HEIGHT - margin); + + textX = modalX + PADDING; + textY = modalY + 58f; + textW = WIDTH - PADDING * 2f; + textH = HEIGHT - PADDING * 3f - BUTTON_H; + + saveW = BUTTON_W; + saveH = BUTTON_H; + saveX = modalX + WIDTH - PADDING - saveW - 14f; + saveY = modalY + HEIGHT - PADDING - saveH; + + cancelW = BUTTON_W; + cancelH = BUTTON_H; + cancelX = saveX - cancelW - 8f; + cancelY = saveY; + } + + private boolean isShiftDown() { + var window = Minecraft.getInstance().getWindow(); + return InputConstants.isKeyDown(window, GLFW.GLFW_KEY_LEFT_SHIFT) || + InputConstants.isKeyDown(window, GLFW.GLFW_KEY_RIGHT_SHIFT); + } + + private int caretFromPoint(float mx, float my) { + String[] lines = buffer.toString().split("\\n", -1); + float localY = my - (textY + TEXT_PADDING) - scroll; + int lineIdx = clamp((int) Math.floor(localY / LINE_H), 0, Math.max(0, lines.length - 1)); + int lineStart = 0; + for (int i = 0; i < lineIdx; i++) { + lineStart += lines[i].length(); + if (i < lines.length - 1) lineStart++; + } + String line = lines.length > 0 ? lines[lineIdx] : ""; + float relX = mx - (textX + TEXT_PADDING) + scrollX; + if (relX < 0f) relX = 0f; + int col = columnForX(line, relX); + return Math.min(buffer.length(), lineStart + col); + } + + private int columnForX(String line, float relX) { + if (line == null || line.isEmpty()) return 0; + TextRenderer tr = ClickGuiRenderer.getIosevkaRegular(); + float size = 15f; + float acc = 0f; + for (int i = 0; i < line.length(); i++) { + String ch = String.valueOf(line.charAt(i)); + float w = ClickGuiRenderer.textWidth(tr, ch, size); + if (acc + w * 0.5f >= relX) return i; + acc += w; + } + return line.length(); + } + + private void insertText(String text) { + if (text == null || text.isEmpty()) return; + String insert = singleLine ? normalizeSingleLine(text) : text; + if (deleteSelection()) { + } + buffer.insert(caret, insert); + caret += insert.length(); + selection.clear(); + preferredColumn = -1; + ensureCaretVisible(); + dragging = false; + } + + private int indexForLineColumn(String[] lines, int line, int column) { + int idx = 0; + for (int i = 0; i < lines.length; i++) { + if (i == line) { + return idx + column; + } + idx += lines[i].length(); + if (i < lines.length - 1) idx++; + } + return buffer.length(); + } + + private void updateSelectionForMove(int target, boolean extend) { + if (extend) { + if (!selection.appliesToLine(0)) selection.update(0, caret); + selection.updateCaret(target); + } else { + selection.clear(); + } + } + + private boolean deleteSelection() { + if (!selection.appliesToLine(0) || !selection.hasRange()) return false; + int start = Math.min(selection.start(), selection.end()); + int end = Math.max(selection.start(), selection.end()) + 1; + start = clamp(start, 0, buffer.length()); + end = clamp(end, 0, buffer.length()); + if (end <= start) { + selection.clear(); + return false; + } + buffer.delete(start, end); + caret = start; + selection.clear(); + return true; + } + + private void ensureCaretVisible() { + String[] lines = buffer.toString().split("\\n", -1); + float visibleY = textH - TEXT_PADDING * 2f; + float contentY = lines.length * LINE_H; + float minScrollY = Math.min(0f, visibleY - contentY); + int[] caretPos = caretLineAndColumn(lines, caret); + float caretY = caretPos[0] * LINE_H + scroll; + float bottom = caretY + LINE_H; + + if (caretY < 0f) { + scroll = clamp(scroll - caretY, minScrollY, 0f); + } else if (bottom > visibleY) { + float diff = bottom - visibleY; + scroll = clamp(scroll - diff, minScrollY, 0f); + } else { + scroll = clamp(scroll, minScrollY, 0f); + } + + TextRenderer tr = ClickGuiRenderer.getIosevkaRegular(); + float size = 15f; + float visibleW = Math.max(1f, textW - TEXT_PADDING * 2f); + float caretOffsetX = 0f; + if (lines.length > 0 && caretPos[0] >= 0 && caretPos[0] < lines.length) { + caretOffsetX = ClickGuiRenderer.textWidth(tr, safeSubstring(lines[caretPos[0]], 0, caretPos[1]), size); + } + float maxWidth = 0f; + for (String line : lines) { + float w = ClickGuiRenderer.textWidth(tr, line, size); + if (w > maxWidth) maxWidth = w; + } + float maxScrollX = Math.max(0f, maxWidth - visibleW); + float caretRelativeX = caretOffsetX - scrollX; + if (caretRelativeX < 0f) { + scrollX = clamp(scrollX + caretRelativeX, 0f, maxScrollX); + } else if (caretRelativeX > visibleW) { + scrollX = clamp(scrollX + (caretRelativeX - visibleW), 0f, maxScrollX); + } else { + scrollX = clamp(scrollX, 0f, maxScrollX); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuCategory.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuCategory.java new file mode 100644 index 0000000..8375f0c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuCategory.java @@ -0,0 +1,59 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + +import dev.loki.lovisual.features.module.ModuleCategory; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +public enum ModulesMenuCategory { + COMBAT("Combat", "swords", "combat"), + MOVEMENT("Movement", "accessibility", "movement"), + PLAYER("Player", "user", "player"), + VISUALS("Visuals", "tree-pine", "visual", "render", "visuals"), + OTHER("Other", "ellipsis", "other", "misc", "miscellaneous", "exploit", "world"); + + private final String title; + private final String icon; + private final List aliases; + + ModulesMenuCategory(String title, String icon, String... aliases) { + this.title = title; + this.icon = icon; + this.aliases = Arrays.asList(aliases); + } + + public String title() { + return title; + } + + public String icon() { + return icon; + } + + boolean matches(ModuleCategory category) { + if (category == null) return this == OTHER; + + String name = category.name().toLowerCase(Locale.ROOT); + for (String alias : aliases) { + if (name.equals(alias) || name.contains(alias)) return true; + } + + if (this == OTHER) { + for (ModulesMenuCategory c : values()) { + if (c == OTHER) continue; + if (c.matches(category)) return false; + } + return true; + } + + return false; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java new file mode 100644 index 0000000..688b594 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPanel.java @@ -0,0 +1,310 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderContext; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderSurface; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class ModulesMenuPanel { + final ModulesMenuCategory category; + final int index; + final List hits = new ArrayList<>(); + final List settingHits = new ArrayList<>(); + final Map enabledAnim = new HashMap<>(); + final Map hoverAnim = new HashMap<>(); + final List selectedSettings = new ArrayList<>(); + + float x; + float y; + float w; + float h; + + float modulesScroll; + float modulesSmoothScroll; + float maxModulesScroll; + float modulesScrollbarX, modulesScrollbarY, modulesScrollbarW, modulesScrollbarH; + float modulesScrollbarHandleY, modulesScrollbarHandleH; + boolean modulesScrollbarDragging; + float modulesScrollbarDragOffset; + + float settingsScroll; + float settingsSmoothScroll; + float maxSettingsScroll; + float settingsScrollbarX, settingsScrollbarY, settingsScrollbarW, settingsScrollbarH; + float settingsScrollbarHandleY, settingsScrollbarHandleH; + boolean settingsScrollbarDragging; + float settingsScrollbarDragOffset; + + float swap; + float anim; + + String selected; + String selectedTitle; + String bindingId; + + ModulesMenuPanel(ModulesMenuCategory category, int index) { + this.category = category; + this.index = index; + } + + void reset() { + selected = null; + selectedTitle = null; + selectedSettings.clear(); + settingsScroll = 0.0f; + settingsSmoothScroll = 0.0f; + modulesScroll = 0.0f; + modulesSmoothScroll = 0.0f; + swap = 0.0f; + hits.clear(); + settingHits.clear(); + } + + void update() { + float dt = AnimationUtility.deltaTime(); + float target = selected == null ? 0.0f : 1.0f; + + swap = AnimationUtility.approach(swap, target, dt, 7.2f); + swap = AnimationUtility.snap(swap, target, 0.0015f); + } + + float enabledAnim(String id, boolean enabled) { + Float prev = enabledAnim.get(id); + float next = AnimationUtility.approach(prev == null ? (enabled ? 1.0f : 0.0f) : prev, enabled ? 1.0f : 0.0f, 0.18f); + enabledAnim.put(id, next); + return next; + } + + float hoverAnim(String id, boolean hover) { + Float prev = hoverAnim.get(id); + float next = AnimationUtility.approach(prev == null ? 0.0f : prev, hover ? 1.0f : 0.0f, 0.18f); + hoverAnim.put(id, next); + return next; + } + + boolean mousePressed(float mx, float my, int button) { + if (selected != null && swap > 0.2f) { + float backY = y + 28.0f * ModulesMenuScreen.computePortScale(); + float backH = 20.0f * ModulesMenuScreen.computePortScale(); + + if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT && ModulesMenuScreen.inside(mx, my, x, backY, w, backH)) { + closeSettings(); + return true; + } + + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, ModulesMenuScreen.computePortScale())) { + SettingHit target = null; + for (SettingHit hit : settingHits) { + if (ModulesMenuScreen.inside(mx, my, hit.x, hit.y, hit.w, hit.h)) { + target = hit; + break; + } + } + for (Setting setting : selectedSettings) { + if (target != null && target.setting == setting) continue; + setting.mouseClickedOutside(mx, my, button); + } + if (target != null) { + target.setting.mouseClicked(mx, my, button); + return true; + } + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && settingsScrollbarHit(mx, my)) { + settingsScrollbarDragging = true; + settingsScrollbarDragOffset = my - settingsScrollbarHandleY; + updateSettingsScrollbarDrag(my); + return true; + } + + return true; + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && modulesScrollbarHit(mx, my)) { + modulesScrollbarDragging = true; + modulesScrollbarDragOffset = my - modulesScrollbarHandleY; + updateModulesScrollbarDrag(my); + return true; + } + + for (ModuleHit hit : hits) { + if (!ModulesMenuScreen.inside(mx, my, hit.x, hit.y, hit.w, hit.h)) continue; + + if (ModulesMenuScreen.isModuleListEditHeld() + && hit.toggleable + && (button == GLFW.GLFW_MOUSE_BUTTON_LEFT || button == GLFW.GLFW_MOUSE_BUTTON_RIGHT)) { + ModulesMenuResolver.toggleModuleListVisibility(hit.id); + return true; + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && hit.toggleable) { + ModulesMenuResolver.toggleEntry(hit.id); + return true; + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_MIDDLE) { + bindingId = hit.id; + ModulesMenuResolver.beginBind(hit.id); + return true; + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + ModulesMenuResolver.ResolvedSettings resolved = ModulesMenuResolver.resolveSettings(hit.id); + if (resolved == null || resolved.settings().isEmpty()) return true; + + selected = resolved.getId(); + selectedTitle = resolved.title(); + selectedSettings.clear(); + selectedSettings.addAll(resolved.settings()); + settingsScroll = 0.0f; + settingsSmoothScroll = 0.0f; + return true; + } + + return true; + } + + return true; + } + + void mouseReleased(float mx, float my, int button) { + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + modulesScrollbarDragging = false; + settingsScrollbarDragging = false; + } + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, ModulesMenuScreen.computePortScale())) { + for (Setting setting : selectedSettings) { + setting.mouseReleased(mx, my, button); + } + } + } + + void scroll(float mx, float my, double amount) { + float delta = (float) (-amount * 20.0f * ModulesMenuScreen.computePortScale()); + + if (selected != null) { + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, ModulesMenuScreen.computePortScale())) { + for (SettingHit hit : settingHits) { + if (!ModulesMenuScreen.inside(mx, my, hit.x, hit.y, hit.w, hit.h)) continue; + if (hit.setting.mouseScrolled(mx, my, amount)) return; + break; + } + } + settingsScroll = AnimationUtility.clamp(settingsScroll + delta, 0.0f, maxSettingsScroll); + } else { + modulesScroll = AnimationUtility.clamp(modulesScroll + delta, 0.0f, maxModulesScroll); + } + } + + boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (selected == null) { + if (keyCode == GLFW.GLFW_KEY_UP) { + modulesScroll = AnimationUtility.clamp(modulesScroll - 20.0f * ModulesMenuScreen.computePortScale(), 0.0f, maxModulesScroll); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_DOWN) { + modulesScroll = AnimationUtility.clamp(modulesScroll + 20.0f * ModulesMenuScreen.computePortScale(), 0.0f, maxModulesScroll); + return true; + } + + if (bindingId != null && !ClickGuiRenderer.waitingForKey) { + bindingId = null; + } + + return false; + } + + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, ModulesMenuScreen.computePortScale())) { + for (Setting setting : selectedSettings) { + if (setting.keyPressed(keyCode, scanCode, modifiers)) return true; + } + } + + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + closeSettings(); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_UP) { + settingsScroll = AnimationUtility.clamp(settingsScroll - 20.0f * ModulesMenuScreen.computePortScale(), 0.0f, maxSettingsScroll); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_DOWN) { + settingsScroll = AnimationUtility.clamp(settingsScroll + 20.0f * ModulesMenuScreen.computePortScale(), 0.0f, maxSettingsScroll); + return true; + } + + return false; + } + + boolean charTyped(char chr, int modifiers) { + if (selected == null) return false; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, ModulesMenuScreen.computePortScale())) { + for (Setting setting : selectedSettings) { + if (setting.charTyped(chr, modifiers)) return true; + } + } + return false; + } + + + void updateScrollbarDrag(float mx, float my) { + if (modulesScrollbarDragging) updateModulesScrollbarDrag(my); + if (settingsScrollbarDragging) updateSettingsScrollbarDrag(my); + } + + boolean isDraggingScrollbar() { + return modulesScrollbarDragging || settingsScrollbarDragging; + } + + private boolean modulesScrollbarHit(float mx, float my) { + return maxModulesScroll > 0.5f && ModulesMenuScreen.inside(mx, my, modulesScrollbarX - 3f * ModulesMenuScreen.computePortScale(), modulesScrollbarY, modulesScrollbarW + 6f * ModulesMenuScreen.computePortScale(), modulesScrollbarH); + } + + private boolean settingsScrollbarHit(float mx, float my) { + return maxSettingsScroll > 0.5f && ModulesMenuScreen.inside(mx, my, settingsScrollbarX - 3f * ModulesMenuScreen.computePortScale(), settingsScrollbarY, settingsScrollbarW + 6f * ModulesMenuScreen.computePortScale(), settingsScrollbarH); + } + + private void updateModulesScrollbarDrag(float my) { + float track = Math.max(1f, modulesScrollbarH - modulesScrollbarHandleH); + float handleY = AnimationUtility.clamp(my - modulesScrollbarDragOffset, modulesScrollbarY, modulesScrollbarY + track); + float t = (handleY - modulesScrollbarY) / track; + modulesScroll = AnimationUtility.clamp(t * maxModulesScroll, 0.0f, maxModulesScroll); + } + + private void updateSettingsScrollbarDrag(float my) { + float track = Math.max(1f, settingsScrollbarH - settingsScrollbarHandleH); + float handleY = AnimationUtility.clamp(my - settingsScrollbarDragOffset, settingsScrollbarY, settingsScrollbarY + track); + float t = (handleY - settingsScrollbarY) / track; + settingsScroll = AnimationUtility.clamp(t * maxSettingsScroll, 0.0f, maxSettingsScroll); + } + + private void closeSettings() { + selected = null; + selectedSettings.clear(); + settingsScroll = 0.0f; + settingsSmoothScroll = 0.0f; + } + + record ModuleHit(String id, 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/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java new file mode 100644 index 0000000..42d68eb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuResolver.java @@ -0,0 +1,90 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +enum ModulesMenuResolver { + ; + + static List buildCards(ModulesMenuCategory category) { + List out = new ArrayList<>(); + if (category == null) return out; + + for (Module module : ModuleManager.getModules()) { + if (module == null) continue; + if (!category.matches(module.getCategory())) continue; + String bind = module.getKeyBindSetting() != null && module.getKeyBindSetting().getValue() != null + ? module.getKeyBindSetting().getValue().get() + : ""; + out.add(new ModuleComponent.CardEntry( + module.name(), + module.getDisplayName(), + "", + bind == null ? "" : bind, + !module.getSettings().isEmpty(), + module.isEnabled(), + true, + module.isShownInModuleList(), + module.getAliases() + )); + } + + out.sort(Comparator.comparing(ModuleComponent.CardEntry::title, String.CASE_INSENSITIVE_ORDER)); + return out; + } + + static void toggleEntry(String id) { + Module module = moduleById(id); + if (module == null) return; + module.toggle(); + } + + static void beginBind(String id) { + Module module = moduleById(id); + if (module == null || module.getKeyBindSetting() == null) return; + ClickGuiRenderer.beginKeyBind(module.getKeyBindSetting()); + } + + static void toggleModuleListVisibility(String id) { + Module module = moduleById(id); + if (module == null) return; + module.toggleShownInModuleList(); + } + + static ResolvedSettings resolveSettings(String id) { + Module module = moduleById(id); + if (module == null) return null; + List settings = module.getSettings(); + if (settings == null || settings.isEmpty()) return null; + for (Setting setting : settings) { + if (setting == null) continue; + setting.preflightI18n(); + } + return new ResolvedSettings(module.name(), module.getDisplayName(), settings); + } + + static Module moduleById(String id) { + if (id == null || id.isBlank()) return null; + return ModuleManager.get(id); + } + + record ResolvedSettings(String id, String title, List settings) { + public String getId() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java new file mode 100644 index 0000000..2d810c5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuScreen.java @@ -0,0 +1,914 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import net.minecraft.client.Minecraft; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiSearch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderContext; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderSurface; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiHintOverlay; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ClipFunction; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.util.ArrayList; +import java.util.List; + +public final class ModulesMenuScreen { + private static final float INPUT_READY_PROGRESS = 0.72f; + private static final float PANEL_W = 115.0f; + private static final float PANEL_H = 240.0f; + private static final float PANEL_GAP = 14.0f; + private static final float HEADER_H = 24.0f; + private static final float SEPARATOR_H = 4.0f; + private static final float MODULE_ROW_H = 20.0f; + private static final float PANEL_RADIUS = 10.0f; + private static final float TEXT_LEFT_PADDING = 10.0f; + + private static final float LIQUID_GLASS_LOGICAL_SCALE = 3.0f; + private static float ACTIVE_PORT_SCALE = LIQUID_GLASS_LOGICAL_SCALE; + + private final List panels = new ArrayList<>(); + + private float areaX; + private float areaY; + private float areaW; + private float areaH; + private float scale = 1.0f; + private float screenAnim = 0.0f; + private boolean openTarget = false; + + private float searchAnim = 0.0f; + private float searchX; + private float searchY; + private float searchW; + private float searchH; + + private TextRenderer regular; + private TextRenderer medium; + private TextRenderer semibold; + + public ModulesMenuScreen() { + ModulesMenuCategory[] values = ModulesMenuCategory.values(); + for (int i = 0; i < values.length; i++) { + panels.add(new ModulesMenuPanel(values[i], i)); + } + } + + private static float easeOutCubic(float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + float inv = 1.0f - t; + return 1.0f - inv * inv * inv; + } + + static float computePortScale() { + return ACTIVE_PORT_SCALE; + } + + private static float computePortScale(float areaW, float areaH) { + float designW = PANEL_W * ModulesMenuCategory.values().length + PANEL_GAP * (ModulesMenuCategory.values().length - 1); + float sideMargin = Math.max(28.0f, areaW * 0.03f); + float verticalReserve = Math.max(56.0f, areaH * 0.10f); + + float fitX = Math.max(1.0f, (areaW - sideMargin * 2.0f) / designW); + float fitY = Math.max(1.0f, (areaH - verticalReserve) / (PANEL_H + 30.0f)); + float fit = Math.min(fitX, fitY); + + return AnimationUtility.clamp(Math.min(LIQUID_GLASS_LOGICAL_SCALE, fit), 2.55f, LIQUID_GLASS_LOGICAL_SCALE); + } + + static boolean isModuleListEditHeld() { + Minecraft client = Minecraft.getInstance(); + if (client == null || client.getWindow() == null || ClickGuiSearch.isActive()) { + return false; + } + return GLFW.glfwGetKey(client.getWindow().handle(), GLFW.GLFW_KEY_E) == GLFW.GLFW_PRESS; + } + + private static float middle(float child, float parent) { + return (parent - child) * 0.5f; + } + + private static float textHeight(TextRenderer renderer, float size) { + return ClickGuiRenderer.textHeight(renderer, size); + } + + static boolean inside(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static int withAlpha(int color, float alpha) { + float a = AnimationUtility.clamp(alpha, 0.0f, 1.0f); + int ca = (color >>> 24) & 0xFF; + int na = Math.round(ca * a); + return (color & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + private static int withAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + private static int mix(int from, int to, float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + + int a = Math.round(((from >>> 24) & 0xFF) * (1.0f - t) + ((to >>> 24) & 0xFF) * t); + int r = Math.round(((from >>> 16) & 0xFF) * (1.0f - t) + ((to >>> 16) & 0xFF) * t); + int g = Math.round(((from >>> 8) & 0xFF) * (1.0f - t) + ((to >>> 8) & 0xFF) * t); + int b = Math.round((from & 0xFF) * (1.0f - t) + (to & 0xFF) * t); + + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + public void open() { + openTarget = true; + } + + public void close() { + openTarget = false; + ClickGuiSearch.deactivate(); + if (!ClickGuiRenderer.isClosingForExit()) { + screenAnim = 0.0f; + resetTransientState(); + } + } + + public boolean isVisible() { + return openTarget || screenAnim > 0.001f; + } + + public void layout(float x, float y, float w, float h) { + this.areaX = x; + this.areaY = y; + this.areaW = Math.max(1.0f, w); + this.areaH = Math.max(1.0f, h); + this.scale = computePortScale(areaW, areaH); + ACTIVE_PORT_SCALE = this.scale; + + float pw = PANEL_W * scale; + float ph = PANEL_H * scale; + float gap = PANEL_GAP * scale; + float total = (pw + gap) * panels.size() - gap; + + float outerPadX = 18.0f * scale; + float outerPadTop = 6.0f * scale; + float outerPadBottom = 26.0f * scale; + + float layoutW = Math.max(total, areaW - outerPadX * 2.0f); + float startX = areaX + (layoutW - total) * 0.5f + (areaW - layoutW) * 0.5f; + startX = Math.max(areaX + outerPadX, startX); + + float py = areaY + outerPadTop + Math.max(0.0f, (areaH - outerPadTop - outerPadBottom - ph) * 0.5f); + for (int i = 0; i < panels.size(); i++) { + ModulesMenuPanel panel = panels.get(i); + float targetX = startX + i * (pw + gap); + float panelT = panelProgress(i); + float eased = easeOutCubic(panelT); + float drop = -(38.0f + i * 3.5f) * scale * (1.0f - eased); + float settle = (float) Math.sin(panelT * Math.PI) * 4.5f * scale; + + panel.x = targetX; + panel.y = py + drop + settle; + panel.w = pw; + panel.h = ph; + panel.anim = panelT; + } + + searchW = 100.0f * scale; + searchH = 20.0f * scale; + searchX = areaX + areaW * 0.5f - searchW * 0.5f; + + float panelBottom = py + ph; + float visibleSearchY = panelBottom + 6.0f * scale; + float maxVisibleSearchY = areaY + areaH - searchH - 8.0f * scale; + if (visibleSearchY > maxVisibleSearchY) { + visibleSearchY = maxVisibleSearchY; + } + + float hiddenSearchY = visibleSearchY + 12.0f * scale; + searchY = AnimationUtility.lerp(hiddenSearchY, visibleSearchY, searchAnim); + } + + public void render(float mouseX, float mouseY) { + ensureFonts(); + ModulesMenuStyle.syncTheme(); + + float dt = AnimationUtility.deltaTime(); + float target = openTarget ? 1.0f : 0.0f; + + screenAnim = AnimationUtility.approach(screenAnim, target, dt, openTarget ? 4.8f : 7.5f); + screenAnim = AnimationUtility.snap(screenAnim, target, 0.01f); + + if (screenAnim <= 0.001f && !openTarget) { + resetTransientState(); + return; + } + + searchAnim = AnimationUtility.approach(searchAnim, ClickGuiSearch.isActive() ? 1.0f : 0.0f, dt, 10.0f); + searchAnim = AnimationUtility.snap(searchAnim, ClickGuiSearch.isActive() ? 1.0f : 0.0f, 0.01f); + + layout(areaX, areaY, areaW, areaH); + + for (ModulesMenuPanel panel : panels) { + if (panel.isDraggingScrollbar()) panel.updateScrollbarDrag(mouseX, mouseY); + float alpha = panelAlpha(panel); + if (alpha <= 0.001f) continue; + try (RenderWarpStack.Scope ignored = pushPanelDropWarp(panel)) { + renderPanelGlass(panel, alpha); + } + } + + for (ModulesMenuPanel panel : panels) { + float alpha = panelAlpha(panel); + if (alpha <= 0.001f) continue; + try (RenderWarpStack.Scope ignored = pushPanelDropWarp(panel)) { + renderPanel(panel, mouseX, mouseY, alpha); + } + } + + renderSearch(mouseX, mouseY); + renderHints(); + + ClickGuiRenderer.flushRenderer(); + } + + public void renderGlassPass(float alphaFactor) { + if (screenAnim <= 0.001f) return; + + float passAlpha = AnimationUtility.clamp(alphaFactor, 0.0f, 1.0f); + for (ModulesMenuPanel panel : panels) { + float alpha = panelAlpha(panel) * passAlpha; + if (alpha <= 0.001f) continue; + try (RenderWarpStack.Scope ignored = pushPanelDropWarp(panel)) { + renderPanelGlass(panel, alpha); + } + } + } + + public boolean mousePressed(float mouseX, float mouseY, int button) { + if (!isInteractive()) return false; + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && inside(mouseX, mouseY, searchX, searchY, searchW, searchH)) { + ClickGuiSearch.setActive(true); + return true; + } + + for (ModulesMenuPanel panel : panels) { + if (!inside(mouseX, mouseY, panel.x, panel.y, panel.w, panel.h)) continue; + return panel.mousePressed(mouseX, mouseY, button); + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && ClickGuiSearch.isActive()) { + ClickGuiSearch.setActive(false); + return true; + } + + return false; + } + + public void mouseReleased(float mouseX, float mouseY, int button) { + if (!isInteractive()) return; + + for (ModulesMenuPanel panel : panels) { + panel.mouseReleased(mouseX, mouseY, button); + } + } + + public boolean mouseScrolled(float mouseX, float mouseY, double amount) { + if (!isInteractive()) return false; + for (ModulesMenuPanel panel : panels) { + if (!inside(mouseX, mouseY, panel.x, panel.y, panel.w, panel.h)) continue; + panel.scroll(mouseX, mouseY, amount); + return true; + } + + return false; + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (!isInteractive()) return false; + if (isHintToggle(keyCode, modifiers)) { + MainConfig config = MainConfig.get(); + config.setClickGuiHintsEnabled(!config.isClickGuiHintsEnabled()); + return true; + } + + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + + if (ctrl && keyCode == GLFW.GLFW_KEY_F) { + ClickGuiSearch.setActive(!ClickGuiSearch.isActive()); + return true; + } + + if (ClickGuiSearch.isActive()) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + ClickGuiSearch.setActive(false); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { + ClickGuiSearch.backspace(); + return true; + } + } + + for (ModulesMenuPanel panel : panels) { + if (panel.keyPressed(keyCode, scanCode, modifiers)) return true; + } + + return false; + } + + private static boolean isHintToggle(int keyCode, int modifiers) { + return keyCode == GLFW.GLFW_KEY_H && (modifiers & GLFW.GLFW_MOD_ALT) != 0; + } + + public boolean charTyped(char chr, int modifiers) { + if (!isInteractive()) return false; + if (ClickGuiSearch.isActive()) { + ClickGuiSearch.append(chr); + return true; + } + + for (ModulesMenuPanel panel : panels) { + if (panel.charTyped(chr, modifiers)) return true; + } + + return false; + } + + private float panelProgress(int index) { + if (panels.isEmpty()) return screenAnim; + float stagger = 0.055f; + float span = Math.max(0.20f, 1.0f - stagger * Math.max(0, panels.size() - 1)); + return AnimationUtility.clamp((screenAnim - index * stagger) / span, 0.0f, 1.0f); + } + + private float panelAlpha(ModulesMenuPanel panel) { + return easeOutCubic(panel.anim); + } + + private boolean isInteractive() { + return openTarget && screenAnim >= INPUT_READY_PROGRESS; + } + + private RenderWarpStack.Scope pushPanelDropWarp(ModulesMenuPanel panel) { + float t = easeOutCubic(panel.anim); + if (t >= 0.999f) return Renderer2D.pushWarp(null); + float inv = 1.0f - t; + return Renderer2D.pushPerspectiveWarp( + panel.x, + panel.y, + panel.w, + panel.h, + 0.0f, + -9.0f * inv, + 0.0f, + 3.4f, + 0.82f, + 0.94f + 0.06f * t + ); + } + + private void resetTransientState() { + for (ModulesMenuPanel panel : panels) { + panel.selected = null; + panel.selectedTitle = null; + panel.selectedSettings.clear(); + panel.settingsScroll = 0.0f; + panel.settingsSmoothScroll = 0.0f; + panel.modulesScroll = 0.0f; + panel.modulesSmoothScroll = 0.0f; + panel.swap = 0.0f; + panel.hits.clear(); + panel.settingHits.clear(); + } + } + + private void renderPanelGlass(ModulesMenuPanel panel, float alpha) { + if (alpha <= 0.001f) return; + + LayoutRender2D.roundedSoftShadow( + panel.x, + panel.y, + panel.w, + panel.h, + PANEL_RADIUS * scale, + 18.0f * scale, + 0.018f, + withAlpha(ModulesMenuStyle.shadow(), alpha) + ); + + float materialAlpha = alpha >= 0.999f ? 1.0f : alpha; + drawLiquidGlass(panel.x, panel.y, panel.w, panel.h, PANEL_RADIUS * scale, true, materialAlpha); + } + + private void renderPanel(ModulesMenuPanel panel, float mouseX, float mouseY, float alpha) { + panel.update(); + + int text = withAlpha(ModulesMenuStyle.text(), alpha); + int muted = withAlpha(ModulesMenuStyle.textMuted(), alpha); + int split = withAlpha(ModulesMenuStyle.split(), alpha); + int veil = withAlpha(ModulesMenuStyle.panelBgGlassDark(), alpha * 0.30f); + LayoutRender2D.roundedQuad(panel.x, panel.y, panel.w, panel.h, PANEL_RADIUS * scale, veil, veil, veil, veil); + + panel.hits.clear(); + panel.settingHits.clear(); + + float titleY = panel.y + middle(textHeight(semibold, 9.0f * scale), HEADER_H * scale) + 0.5f * scale; + ClickGuiRenderer.drawText(semibold, panel.category.title(), panel.x + TEXT_LEFT_PADDING * scale, titleY, 9.0f * scale, text, false); + drawCategoryIcon(panel, muted); + + if (panel.swap < 0.999f) { + renderModulePage(panel, mouseX, mouseY, alpha * (1.0f - panel.swap)); + } + + if (panel.swap > 0.001f) { + renderSettingsPage(panel, mouseX, mouseY, alpha * panel.swap); + } + + LayoutRender2D.rect( + panel.x + 8.0f * scale, + panel.y + HEADER_H * scale, + panel.w - 16.0f * scale, + 0.5f * scale, + split + ); + } + + private void renderModulePage(ModulesMenuPanel panel, float mouseX, float mouseY, float alpha) { + if (alpha <= 0.001f) return; + + float pageX = panel.x - panel.w * panel.swap; + float listY = panel.y + (HEADER_H + SEPARATOR_H - 1.0f) * scale; + float clipY = panel.y + (HEADER_H + SEPARATOR_H) * scale; + float clipH = panel.h - (HEADER_H + SEPARATOR_H) * scale - 0.5f * scale; + + boolean clipped = ClipFunction.pushRaw(panel.x, clipY, panel.w, clipH); + ClickGuiRenderer.flushRenderer(); + boolean panelShapeClip = ClipFunction.pushRoundedRect(panel.x, panel.y, panel.w, panel.h, PANEL_RADIUS * scale); + float listW = Math.max(1.0f, panel.w - 7.0f * scale); + + float y = listY - panel.modulesSmoothScroll; + float total = 0.0f; + + try { + List entries = ModulesMenuResolver.buildCards(panel.category); + for (ModuleComponent.CardEntry entry : entries) { + if (!ClickGuiSearch.matches(entry.title(), entry.searchAliases())) continue; + + if (y + MODULE_ROW_H * scale >= clipY && y <= clipY + clipH) { + renderModuleRow(panel, entry, pageX, y, listW, mouseX, mouseY, alpha); + } + + y += MODULE_ROW_H * scale; + total += MODULE_ROW_H * scale; + } + } finally { + ClickGuiRenderer.flushRenderer(); + if (panelShapeClip) ClipFunction.pop(); + } + + panel.maxModulesScroll = Math.max(0.0f, total - clipH); + panel.modulesScroll = AnimationUtility.clamp(panel.modulesScroll, 0.0f, panel.maxModulesScroll); + panel.modulesSmoothScroll = AnimationUtility.approach(panel.modulesSmoothScroll, panel.modulesScroll, 0.22f); + panel.modulesSmoothScroll = AnimationUtility.snap(panel.modulesSmoothScroll, panel.modulesScroll, 0.05f); + + if (clipped) ClipFunction.pop(); + + renderScrollbar(panel, panel.modulesSmoothScroll, panel.maxModulesScroll, clipY, clipH, alpha, mouseX, mouseY); + } + + private void renderModuleRow( + ModulesMenuPanel panel, + ModuleComponent.CardEntry entry, + float x, + float y, + float rowW, + float mouseX, + float mouseY, + float alpha + ) { + float h = MODULE_ROW_H * scale; + boolean hover = inside(mouseX, mouseY, x, y, rowW, h); + + float enabled = panel.enabledAnim(entry.getId(), entry.enabled()); + float hoverAnim = panel.hoverAnim(entry.getId(), hover); + + renderModuleRowHover(panel, x, y, rowW, h, mouseX, mouseY, alpha, hoverAnim); + + String label = panel.bindingId != null && panel.bindingId.equals(entry.getId()) + ? bindingLabel(entry) + : entry.title(); + + boolean moduleListEdit = isModuleListEditHeld(); + String moduleListLabel = entry.shownInModuleList() ? "[ON]" : "[OFF]"; + float moduleListTextSize = 7.2f * scale; + float moduleListTextW = moduleListEdit + ? ClickGuiRenderer.textWidth(semibold, moduleListLabel, moduleListTextSize) + : 0.0f; + float moduleListW = moduleListEdit ? moduleListTextW + 3.0f * scale : 0.0f; + float checkReserve = entry.enabled() ? 18.0f * scale : 6.0f * scale; + float moduleListX = x + rowW - checkReserve - moduleListW - 3.0f * scale; + float moduleListY = y + middle(textHeight(semibold, moduleListTextSize), h) - 0.5f * scale; + + int nameColor = mix(withAlpha(ModulesMenuStyle.textMuted(), alpha), withAlpha(ModulesMenuStyle.text(), alpha), 0.25f + 0.75f * enabled + 0.20f * hoverAnim); + + float nameX = x + (TEXT_LEFT_PADDING + 2.0f * enabled) * scale; + float nameY = y + middle(textHeight(regular, 8.0f * scale), h) - 0.5f * scale; + float nameMaxW = moduleListEdit + ? Math.max(12.0f * scale, moduleListX - nameX - 5.0f * scale) + : Math.max(12.0f * scale, rowW - (nameX - x) - 18.0f * scale); + String visibleLabel = ClickGuiRenderer.fitText(regular, label, 8.0f * scale, nameMaxW); + + ClickGuiRenderer.drawText(regular, visibleLabel, nameX, nameY, 8.0f * scale, nameColor, false); + + if (moduleListEdit) { + int listColor = entry.shownInModuleList() ? ModulesMenuStyle.MODULE_LIST_ON : ModulesMenuStyle.MODULE_LIST_OFF; + ClickGuiRenderer.drawText(semibold, moduleListLabel, moduleListX, moduleListY, moduleListTextSize, withAlpha(listColor, alpha), false); + } + + int divider = withAlpha(ModulesMenuStyle.text(), alpha * 0.02f); + LayoutRender2D.rect(x + 3.0f * scale, y + h, Math.max(1f, rowW - 6.0f * scale), 0.5f * scale, divider); + + if (enabled > 0.001f) { + float check = 6.0f * scale; + float checkX = x + rowW - 15.0f * scale - 2.0f * scale * enabled; + float checkY = y + 7.0f * scale; + + Renderer2D.COLOR.svg( + "check", + checkX, + checkY, + check, + check, + SvgRenderOptions.overrideColor(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())); + } + + private void renderModuleRowHover(ModulesMenuPanel panel, + float x, + float y, + float w, + float h, + float mouseX, + float mouseY, + float alpha, + float hoverAnim) { + if (hoverAnim <= 0.001f || alpha <= 0.001f) return; + + float rx = x + 3.0f * scale; + float ry = y + 2.0f * scale; + float rw = w - 6.0f * scale; + float rh = h - 4.0f * scale; + if (rw <= 0.5f || rh <= 0.5f) return; + + float radius = 5.0f * scale; + int hoverBg = withAlpha(ModulesMenuStyle.rowHover(), alpha * hoverAnim); + int glow = ModulesMenuStyle.rowHoverGlow(alpha * hoverAnim); + + float cx = AnimationUtility.clamp(mouseX, rx, rx + rw); + float cy = AnimationUtility.clamp(mouseY, ry, ry + rh); + float glowRadius = Math.max(rw, rh) * 0.82f; + + boolean ownsPanelClip = false; + boolean panelClip = ClipFunction.isShapeClipActive(); + if (!panelClip) { + panelClip = ClipFunction.pushRoundedRect(panel.x, panel.y, panel.w, panel.h, PANEL_RADIUS * scale); + ownsPanelClip = panelClip; + } + if (!panelClip) { + DebugLog.warnOnChange( + "modules.hover.parent.clip.failed", + panel.category.title(), + "ModulesMenuScreen: hover clipped draw skipped because parent rounded glass clip failed. panel=%s panelBounds=[%.2f, %.2f, %.2f, %.2f]", + panel.category.title(), + panel.x, panel.y, panel.w, panel.h + ); + return; + } + + try { + /* + * Only the glass/dropdown shape needs stencil here. + * The hover itself is already a rounded shader primitive and radialGlowMasked() is already + * clipped to the hover rounded rect. A second nested stencil layer for the same hover shape + * makes the packed depth-stencil path dependent on ref=2 and can cull the whole hover draw. + * + * Effective result: + * - rounded hover fill/glow shape is produced by Renderer2D shaders; + * - parent rounded glass clip cuts both of them at the dropdown rounded bottom/edges. + */ + if (((glow >>> 24) & 0xFF) > 0) { + Renderer2D.COLOR.radialGlowMasked(rx, ry, rw, rh, radius, 0.0f, glowRadius, cx, cy, glow); + } + + if (((hoverBg >>> 24) & 0xFF) > 0) { + LayoutRender2D.roundedQuad(rx, ry, rw, rh, radius, hoverBg, hoverBg, hoverBg, hoverBg); + } + } finally { + Renderer2D.flushBatch(Renderer2D.FlushReason.SCISSOR); + if (ownsPanelClip) ClipFunction.pop(); + } + } + + private void renderSettingsPage(ModulesMenuPanel panel, float mouseX, float mouseY, float alpha) { + if (alpha <= 0.001f) return; + + float pageX = panel.x + panel.w * (1.0f - panel.swap); + float settingsPadLeft = 6.0f * scale; + float settingsPadRight = 13.0f * scale; + float settingsX = pageX + settingsPadLeft; + float settingsW = Math.max(1.0f, panel.w - settingsPadLeft - settingsPadRight); + float settingsTop = panel.y + (HEADER_H + SEPARATOR_H) * scale; + float backRowY = panel.y + 28.0f * scale; + float backRowH = 20.0f * scale; + boolean pageClip = ScissorFunction.pushRaw(panel.x, panel.y, panel.w, panel.h); + + boolean headerClip = ScissorFunction.pushRaw(panel.x, panel.y, panel.w, panel.h); + + if (inside(mouseX, mouseY, pageX, backRowY, panel.w, backRowH)) { + int hoverBg = withAlpha(ModulesMenuStyle.rowHover(), alpha); + LayoutRender2D.roundedQuad( + pageX + 3.0f * scale, + backRowY + scale, + panel.w - 6.0f * scale, + backRowH - 2.0f * scale, + 5.0f * scale, + hoverBg, + hoverBg, + hoverBg, + hoverBg + ); + } + + String title = panel.selectedTitle == null ? "Settings" : panel.selectedTitle; + title = ClickGuiRenderer.fitText(regular, title, 8.0f * scale, panel.w - 18.0f * scale); + + ClickGuiRenderer.drawText( + regular, + title, + pageX + TEXT_LEFT_PADDING * scale, + settingsTop + middle(textHeight(regular, 8.0f * scale), HEADER_H * scale) - scale, + 8.0f * scale, + withAlpha(ModulesMenuStyle.text(), alpha), + false + ); + + if (headerClip) ScissorFunction.pop(); + + float clipY = settingsTop + HEADER_H * scale; + float clipH = panel.h - HEADER_H * 2.0f * scale - SEPARATOR_H * scale - 0.5f * scale - 5.0f * scale; + + boolean clipped = ScissorFunction.pushRaw(settingsX, clipY, settingsW, clipH); + + float y = clipY - panel.settingsSmoothScroll; + float total = 0.0f; + + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.MODULES, scale)) { + for (Setting setting : panel.selectedSettings) { + float vis = setting.updateVisibilityAnim(); + if (!setting.isVisibilityTargetVisible() && vis <= 0.01f) continue; + + float baseH = setting.getHeight(); + float h = baseH * vis; + + if (h > 0.5f) { + panel.settingHits.add(new ModulesMenuPanel.SettingHit(setting, settingsX, y, settingsW, h)); + + if (y + h >= clipY && y <= clipY + clipH) { + float slide = (-baseH + baseH * vis) * 0.5f; + + boolean settingClipped = ScissorFunction.pushRaw(settingsX, y, settingsW, h); + + setting.render(settingsX, y + slide, settingsW, mouseX, mouseY); + + if (settingClipped) ScissorFunction.pop(); + } + } + + y += h; + total += h; + } + } + + panel.maxSettingsScroll = Math.max(0.0f, total - clipH); + panel.settingsScroll = AnimationUtility.clamp(panel.settingsScroll, 0.0f, panel.maxSettingsScroll); + panel.settingsSmoothScroll = AnimationUtility.approach(panel.settingsSmoothScroll, panel.settingsScroll, 0.20f); + panel.settingsSmoothScroll = AnimationUtility.snap(panel.settingsSmoothScroll, panel.settingsScroll, 0.05f); + + if (clipped) ScissorFunction.pop(); + + renderScrollbar(panel, panel.settingsSmoothScroll, panel.maxSettingsScroll, clipY, clipH, alpha, mouseX, mouseY); + if (pageClip) ScissorFunction.pop(); + } + + private void renderSearch(float mouseX, float mouseY) { + if (searchAnim <= 0.001f && !ClickGuiSearch.isActive()) return; + + float alpha = easeOutCubic(screenAnim) * searchAnim; + if (alpha <= 0.001f) return; + + float materialAlpha = alpha >= 0.999f ? 1.0f : alpha; + float radius = 6.0f * scale; + + drawLiquidGlass(searchX, searchY, searchW, searchH, radius, false, materialAlpha); + + int veil = withAlpha(ModulesMenuStyle.panelBgGlassDark(), alpha * 0.25f); + LayoutRender2D.roundedQuad(searchX, searchY, searchW, searchH, radius, veil, veil, veil, veil); + + String text = ClickGuiSearch.getText(); + String draw = text == null || text.isBlank() ? "Search..." : text; + int color = text == null || text.isBlank() ? withAlpha(ModulesMenuStyle.textFaint(), alpha) : withAlpha(ModulesMenuStyle.text(), alpha); + + ClickGuiRenderer.drawText( + regular, + draw, + searchX + 8.0f * scale, + searchY + middle(textHeight(regular, 8.0f * scale), searchH), + 8.0f * scale, + color, + false + ); + } + + private void renderHints() { + MainConfig config = MainConfig.get(); + if (!config.isClickGuiHintsEnabled()) return; + + float alpha = easeOutCubic(screenAnim); + ClickGuiHintOverlay.renderBottomLeft( + areaX, + areaY, + areaW, + areaH, + scale, + alpha, + ClickGuiI18n.tr("clickgui.hints.modules.toggle", "LMB - toggle module"), + ClickGuiI18n.tr("clickgui.hints.modules.settings", "RMB - settings"), + ClickGuiI18n.tr("clickgui.hints.modules.module_list", "E - edit ModuleList"), + ClickGuiI18n.tr("clickgui.hints.modules.search", "Ctrl+F - search"), + ClickGuiI18n.tr("clickgui.hints.modules.close", "Esc - close ClickGui"), + ClickGuiI18n.tr("clickgui.hints.modules.hide", "Alt+H - hide hints") + ); + } + + private void drawLiquidGlass(float x, float y, float w, float h, float radius, boolean panel, float alpha) { + if (alpha <= 0.001f) return; + + float materialAlpha = AnimationUtility.clamp(alpha, 0.0f, 1.0f); + float blurAlpha = AnimationUtility.clamp(materialAlpha * (panel ? 1.20f : 1.04f), 0.0f, 1.0f); + float thickness = (panel ? 15.5f : 12.5f) * scale; + float fresnelPower = panel ? -18.0f : -16.0f; + float fresnelAlpha = 1.0f; + float baseAlpha = panel ? 0.78f : 0.84f; + float fresnelMix = panel ? 0.52f : 0.48f; + float distortion = (panel ? 0.190f : 0.155f) * materialAlpha; + + Renderer2D.COLOR.blurRect( + x, + y, + w, + h, + radius, + ClickGuiRenderer.clickGuiBlurQuality(), + 1.0f, + blurAlpha, + 0xFFFFFF + ); + + Renderer2D.COLOR.liquidGlassRect( + x, + y, + w, + h, + radius, + thickness, + 0xFFFFFFFF, + materialAlpha, + blurAlpha, + fresnelPower, + fresnelAlpha, + baseAlpha, + fresnelMix, + distortion, + 0.0f + ); + } + + private void drawCategoryIcon(ModulesMenuPanel panel, int color) { + float icon = 8.0f * scale; + float ix = panel.x + panel.w - 10.0f * scale - icon; + float iy = panel.y + middle(icon, HEADER_H * scale) + 0.5f * scale; + + Renderer2D.COLOR.svg(panel.category.icon(), ix, iy, icon, icon, SvgRenderOptions.overrideColor(color)); + } + + private void renderScrollbar(ModulesMenuPanel panel, float scroll, float maxScroll, float clipY, float clipH, float alpha, float mouseX, float mouseY) { + boolean settingsPage = panel.selected != null; + if (maxScroll <= 0.5f) { + if (settingsPage) { + panel.settingsScrollbarX = panel.settingsScrollbarY = panel.settingsScrollbarW = panel.settingsScrollbarH = 0f; + } else { + panel.modulesScrollbarX = panel.modulesScrollbarY = panel.modulesScrollbarW = panel.modulesScrollbarH = 0f; + } + return; + } + + float trackW = 2.5f * scale; + float trackX = panel.x + panel.w - 5.0f * scale; + float trackY = clipY + 5.0f * scale; + float trackH = Math.max(1.0f, clipH - 10.0f * scale); + + int trackA = ModulesMenuStyle.scrollTrackA(alpha); + int trackB = ModulesMenuStyle.scrollTrackB(alpha); + int handleA = ModulesMenuStyle.scrollHandleA(alpha); + int handleB = ModulesMenuStyle.scrollHandleB(alpha); + + boolean scrollbarHovered = inside(mouseX, mouseY, trackX - 3.0f * scale, trackY, trackW + 6.0f * scale, trackH); + boolean scrollbarDragging = settingsPage ? panel.settingsScrollbarDragging : panel.modulesScrollbarDragging; + if (scrollbarHovered || scrollbarDragging) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + + LayoutRender2D.roundedQuad( + trackX, + trackY, + trackW, + trackH, + trackW * 0.5f, + trackA, + trackB, + trackB, + trackA + ); + + float handleH = Math.max(18.0f * scale, trackH * (trackH / (trackH + maxScroll))); + float handleY = trackY + (trackH - handleH) * (scroll / Math.max(1.0f, maxScroll)); + if (settingsPage) { + panel.settingsScrollbarX = trackX; + panel.settingsScrollbarY = trackY; + panel.settingsScrollbarW = trackW; + panel.settingsScrollbarH = trackH; + panel.settingsScrollbarHandleY = handleY; + panel.settingsScrollbarHandleH = handleH; + } else { + panel.modulesScrollbarX = trackX; + panel.modulesScrollbarY = trackY; + panel.modulesScrollbarW = trackW; + panel.modulesScrollbarH = trackH; + panel.modulesScrollbarHandleY = handleY; + panel.modulesScrollbarHandleH = handleH; + } + + LayoutRender2D.roundedQuad( + trackX, + handleY, + trackW, + handleH, + trackW * 0.5f, + handleA, + handleB, + handleB, + handleA + ); + } + + private void ensureFonts() { + if (regular == null) + regular = Fonts.renderer("Onest", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + if (medium == null) medium = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, regular); + if (semibold == null) semibold = Fonts.renderer("OnestBold", FontInfo.Type.Regular, medium); + } + + private String bindingLabel(ModuleComponent.CardEntry entry) { + String pending = ClickGuiRenderer.getPendingBindDisplay(); + if (pending != null) return pending; + + String bind = entry.bindLabel(); + return bind == null || bind.isBlank() ? "Binding..." : "Key: " + bind; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuStyle.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuStyle.java new file mode 100644 index 0000000..59634c5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuStyle.java @@ -0,0 +1,131 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; + +enum ModulesMenuStyle { + ; + static final int MODULE_LIST_ON = 0xFF5DE878; + static final int MODULE_LIST_OFF = 0xFFFF5E66; + + private static int text = 0xFFFFFFFF; + private static int textMuted = 0xBFFFFFFF; + private static int textFaint = 0x80FFFFFF; + private static int panelBgGlassDark = 0x660C0C0C; + private static int panelStroke = 0x2EFFFFFF; + private static int split = 0x0DFFFFFF; + private static int rowHover = 0x12FFFFFF; + private static int rowHoverGlow = 0x4DFFFFFF; + private static int shadow = 0x42000000; + + static void syncTheme() { + Themes.Theme theme = Theme.theme(); + if (theme == null) return; + + int accent = forceAlpha(theme.accent(), 255); + int accentSoft = forceAlpha(theme.accentSoft(), 255); + int surface = forceAlpha(theme.surface(), 255); + int surfaceHover = forceAlpha(theme.surfaceHover(), 255); + int stroke = forceAlpha(theme.strokeSoft(), 255); + + text = forceAlpha(theme.textPrimary(), 255); + textMuted = withAlpha(theme.textMuted(), 210); + textFaint = withAlpha(theme.textMuted(), 135); + + panelBgGlassDark = withAlpha(mix(surface, accent, 0.070f), 116); + panelStroke = withAlpha(mix(stroke, accent, 0.155f), 76); + split = withAlpha(mix(stroke, accent, 0.120f), 22); + rowHover = withAlpha(mix(surfaceHover, accent, 0.125f), 34); + rowHoverGlow = withAlpha(mix(accentSoft, accent, 0.42f), 92); + shadow = withAlpha(0xFF000000, 72); + } + + static int text() { + return text; + } + + static int textMuted() { + return textMuted; + } + + static int textFaint() { + return textFaint; + } + + static int panelBgGlassDark() { + return panelBgGlassDark; + } + + static int panelStroke() { + return panelStroke; + } + + static int split() { + return split; + } + + static int rowHover() { + return rowHover; + } + + static int rowHoverGlow(float alpha) { + return withAlpha(rowHoverGlow, alpha); + } + + static int shadow() { + return shadow; + } + + static int scrollTrackA(float alpha) { + return withAlpha(SettingsGuiPalette.current().moduleScrollTrackA(), alpha); + } + + static int scrollTrackB(float alpha) { + return withAlpha(SettingsGuiPalette.current().moduleScrollTrackB(), alpha); + } + + static int scrollHandleA(float alpha) { + return withAlpha(SettingsGuiPalette.current().moduleScrollHandleA(), alpha); + } + + static int scrollHandleB(float alpha) { + return withAlpha(SettingsGuiPalette.current().moduleScrollHandleB(), alpha); + } + + static int withAlpha(int color, float alpha) { + float a = AnimationUtility.clamp(alpha, 0.0f, 1.0f); + int ca = (color >>> 24) & 0xFF; + int na = Math.round(ca * a); + return (color & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + static int withAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + static int forceAlpha(int color, int alpha) { + return withAlpha(color, alpha); + } + + static int mix(int from, int to, float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + + int a = Math.round(((from >>> 24) & 0xFF) * (1.0f - t) + ((to >>> 24) & 0xFF) * t); + int r = Math.round(((from >>> 16) & 0xFF) * (1.0f - t) + ((to >>> 16) & 0xFF) * t); + int g = Math.round(((from >>> 8) & 0xFF) * (1.0f - t) + ((to >>> 8) & 0xFF) * t); + int b = Math.round((from & 0xFF) * (1.0f - t) + (to & 0xFF) * t); + + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java new file mode 100644 index 0000000..02c1e40 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuScreen.java @@ -0,0 +1,669 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiSearch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.config.ConfigProfilesComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main.MainSettingsComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.SettingsPanelComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other.BackgroundComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other.CategoryContainerComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other.SearchComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.relations.RelationsComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.theme.ThemeComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.theme.ThemeEditorPreviewComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiHintOverlay; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.theme.EditableClickGuiTheme; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public final class MenuScreen { + private static final float INPUT_READY_PROGRESS = 0.72f; + private static final float S = 2f; + private static final float MENU_W = 400f; + private static final float MENU_H = 250f; + private final BackgroundComponent backgroundComponent = new BackgroundComponent(); + private final CategoryContainerComponent categoryContainer = new CategoryContainerComponent(); + private final SearchComponent searchComponent = new SearchComponent(); + private final ModuleComponent moduleComponent = new ModuleComponent(); + private final ThemeComponent themeComponent = new ThemeComponent(); + private final ThemeEditorPreviewComponent themeEditorPreview = new ThemeEditorPreviewComponent(); + private final MainSettingsComponent mainSettingsComponent = new MainSettingsComponent(); + private final RelationsComponent relationsComponent = new RelationsComponent(); + private final ConfigProfilesComponent configProfilesComponent = new ConfigProfilesComponent(); + private final SettingsPanelComponent settingsPanel = new SettingsPanelComponent(); + private Category category = Category.HUD; + private float menuX; + private float menuY; + private float menuW; + private float menuH; + private float shellX; + private float shellY; + private float shellW; + private float shellH; + private float categoryX; + private float categoryY; + private float searchX; + private float searchY; + private float menuLinePulsePhase; + private float menuLineHSweep; + private float menuLineVSweep; + private float menuLineHDir = 1f; + private float menuLineVDir = 1f; + private float menuMaskAnim = 0f; + private float screenAnim = 0f; + private boolean openTarget = false; + private EditableClickGuiTheme activeThemeEditor; + + private static float easeOutCubic(float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + float inv = 1.0f - t; + return 1.0f - inv * inv * inv; + } + + private static float easeOutQuint(float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + float inv = 1.0f - t; + return 1.0f - inv * inv * inv * inv * inv; + } + + private static float smootherStep(float t) { + t = AnimationUtility.clamp(t, 0.0f, 1.0f); + return t * t * t * (t * (t * 6.0f - 15.0f) + 10.0f); + } + + private static List visibleEntries(List src) { + if (!ClickGuiSearch.isActive() || ClickGuiSearch.getText().isEmpty()) return src; + List out = new ArrayList<>(); + for (ModuleComponent.CardEntry e : src) { + if (ClickGuiSearch.matches(e.title(), e.searchAliases())) out.add(e); + } + return out; + } + + public void clearScreens() { + } + + public void markDirty() { + } + + public void open() { + openTarget = true; + } + + public void close() { + openTarget = false; + settingsPanel.mouseReleased(0f, 0f, 0); + relationsComponent.resetScroll(); + activeThemeEditor = null; + themeEditorPreview.reset(); + if (!ClickGuiRenderer.isClosingForExit()) { + screenAnim = 0.0f; + } + } + + public boolean isVisible() { + return openTarget || screenAnim > 0.001f; + } + + public void render(float areaX, float areaY, float areaW, float areaH, float mx, float my) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + float dt = AnimationUtility.deltaTime(); + screenAnim = AnimationUtility.approach(screenAnim, openTarget ? 1.0f : 0.0f, dt, openTarget ? 8.5f : 7.2f); + screenAnim = AnimationUtility.snap(screenAnim, openTarget ? 1.0f : 0.0f, 0.002f); + if (screenAnim <= 0.001f && !openTarget) return; + + layout(areaX, areaY, areaW, areaH); + boolean hudPanelActive = settingsPanel.shouldMaskMenu(); + float maskTarget = hudPanelActive ? 1f : 0f; + menuMaskAnim = AnimationUtility.approach(menuMaskAnim, maskTarget, dt, 10f); + menuMaskAnim = AnimationUtility.snap(menuMaskAnim, maskTarget, 0.01f); + + boolean renderMain = !hudPanelActive; + if (activeThemeEditor != null && !settingsPanel.isActive()) { + activeThemeEditor = null; + themeEditorPreview.reset(); + } + float distanceProgress = easeOutQuint(screenAnim); + float fadeProgress = smootherStep(screenAnim); + float previousAlpha = ClickGuiRenderer.setRenderAlphaMultiplier(fadeProgress); + Renderer2D.COLOR.setAlpha(fadeProgress); + try (RenderWarpStack.Scope ignored = pushLifecycleWarp(distanceProgress)) { + if (renderMain) { + renderPanelShadow(palette); + + backgroundComponent.render(shellX, shellY, shellW, shellH, S); + renderShellChrome(palette); + + categoryContainer.render(categoryX, categoryY, mx, my, category, S); + searchComponent.render(searchX, searchY, 80f * S, 15f * S); + + if (category == Category.THEMES) { + themeComponent.render(menuX, menuY, menuW, menuH, mx, my, S); + } else if (category == Category.MAIN_SETTINGS) { + mainSettingsComponent.render(menuX, menuY, menuW, menuH, mx, my, S); + } else if (category == Category.RELATIONS) { + relationsComponent.render(menuX, menuY, menuW, menuH, mx, my, S); + } else if (category == Category.CONFIGS) { + configProfilesComponent.render(menuX, menuY, menuW, menuH, mx, my, S); + } else { + List entries = visibleEntries(MenuSettingsResolver.buildCards(category)); + moduleComponent.render(menuX, menuY, menuW, menuH, entries, S); + } + } + + if (renderMain && menuMaskAnim > 0.01f) { + int c0 = LayoutRender2D.alpha(palette.menuMaskLeft(), menuMaskAnim * fadeProgress); + int c1 = LayoutRender2D.alpha(palette.menuMaskRight(), menuMaskAnim * fadeProgress); + LayoutRender2D.roundedQuad(shellX, shellY, shellW, shellH, 8f * S, c0, c1, c1, c0); + } + + if (activeThemeEditor != null && settingsPanel.isActive()) { + themeEditorPreview.render(activeThemeEditor, menuX, menuY, menuW, menuH, mx, my, S); + } + + settingsPanel.render(menuX, menuY, menuW, menuH, mx, my, S); + } finally { + Renderer2D.COLOR.setAlpha(1.0f); + ClickGuiRenderer.restoreRenderAlphaMultiplier(previousAlpha); + } + + if (renderMain && !settingsPanel.isActive()) { + renderContextHints(areaX, areaY, areaW, areaH, fadeProgress); + } + } + + + private void renderContextHints(float areaX, float areaY, float areaW, float areaH, float alpha) { + MainConfig config = MainConfig.get(); + if (!config.isClickGuiHintsEnabled()) return; + + String primary; + String secondary; + String tertiary; + switch (category) { + case HUD -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.hud.toggle", "LMB - toggle HUD element"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.hud.settings", "RMB - open element settings"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.hud.drag", "Open settings - edit widget position"); + } + case UI -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.ui.toggle", "LMB - toggle UI element"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.ui.settings", "RMB - open element settings"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.ui.search", "Ctrl+F - search cards"); + } + case THEMES -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.themes.select", "LMB - select theme"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.themes.edit", "Edit - open theme editor"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.themes.scroll", "Wheel - scroll themes"); + } + case MAIN_SETTINGS -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.main.select", "LMB - change setting"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.main.category", "Left list - switch setting group"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.main.scroll", "Wheel - scroll settings"); + } + case RELATIONS -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.relations.mode", "Top pills - choose relation list"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.relations.add", "User-plus icon - pick online player"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.relations.search", "Search - filter names"); + } + case CONFIGS -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.configs.select", "LMB - select config"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.configs.rename", "Click name - rename config"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.configs.enter", "Enter/Esc - confirm or cancel text edit"); + } + default -> { + primary = ClickGuiI18n.tr("clickgui.hints.settings.generic.select", "LMB - select"); + secondary = ClickGuiI18n.tr("clickgui.hints.settings.generic.scroll", "Wheel - scroll"); + tertiary = ClickGuiI18n.tr("clickgui.hints.settings.generic.search", "Ctrl+F - search"); + } + } + + ClickGuiHintOverlay.renderBottomLeft( + areaX, + areaY, + areaW, + areaH, + S, + alpha, + primary, + secondary, + tertiary, + ClickGuiI18n.tr("clickgui.hints.settings.close", "Esc - close ClickGUI"), + ClickGuiI18n.tr("clickgui.hints.settings.hide", "Alt+H - hide hints") + ); + } + + private static boolean isHintToggle(int keyCode, int modifiers) { + return keyCode == GLFW.GLFW_KEY_H && (modifiers & GLFW.GLFW_MOD_ALT) != 0; + } + + public boolean mouseClicked(float mx, float my, int button) { + if (!isInteractive()) return false; + if (button != 0 && button != 1) return false; + + if (activeThemeEditor != null && themeEditorPreview.mouseClicked(activeThemeEditor, mx, my, button)) { + settingsPanel.close(); + activeThemeEditor = null; + themeEditorPreview.reset(); + return true; + } + + if (settingsPanel.mouseClicked(mx, my, button, S)) return true; + if (settingsPanel.blocksMenuInput()) return true; + + if (searchComponent.click(searchX, searchY, 80f * S, 15f * S, mx, my, button)) { + return true; + } + + Category hitCategory = categoryContainer.click(categoryX, categoryY, mx, my, S); + if (hitCategory != null) { + if (hitCategory != category) { + category = hitCategory; + moduleComponent.resetScroll(); + themeComponent.resetScroll(); + mainSettingsComponent.resetScroll(); + relationsComponent.resetScroll(); + configProfilesComponent.resetScroll(); + } + return true; + } + + if (category == Category.THEMES) { + if (themeComponent.mousePressedScrollbar(mx, my, button)) return true; + ThemeComponent.ThemeAction action = themeComponent.click(mx, my, button); + if (action == null) return false; + if (action.type() == ThemeComponent.ThemeActionType.SELECT && action.themeId() != null) { + Theme.setTheme(action.themeId()); + } else if (action.type() == ThemeComponent.ThemeActionType.ADD) { + openThemeEditor(EditableClickGuiTheme.createFromCurrent()); + } else if (action.type() == ThemeComponent.ThemeActionType.EDIT && action.themeId() != null) { + openThemeEditor(EditableClickGuiTheme.existing(action.themeId())); + } else if (action.type() == ThemeComponent.ThemeActionType.DELETE && action.themeId() != null) { + Theme.deleteCustomTheme(action.themeId()); + } + return true; + } + + if (category == Category.CONFIGS) { + if (configProfilesComponent.mousePressedScrollbar(mx, my, button)) return true; + return configProfilesComponent.click(mx, my, button); + } + + if (category == Category.MAIN_SETTINGS) { + if (mainSettingsComponent.mousePressedScrollbar(mx, my, button)) return true; + return mainSettingsComponent.mouseClicked(mx, my, button); + } + + if (category == Category.RELATIONS) { + if (relationsComponent.mousePressedScrollbar(mx, my, button)) return true; + return relationsComponent.click(mx, my, button); + } + + if (moduleComponent.mousePressedScrollbar(mx, my, button)) return true; + ModuleComponent.CardAction action = moduleComponent.click(mx, my, button); + if (action == null) return false; + if (action.toggleable() && (action.statusClick() || action.leftClick())) { + toggleEntry(action.getId()); + } + if (action.rightClick()) { + openSettings(action.getId()); + } + return true; + } + + public void mouseReleased(float mx, float my, int button) { + if (!isInteractive()) return; + settingsPanel.mouseReleased(mx, my, button); + themeComponent.mouseReleased(button); + mainSettingsComponent.mouseReleased(mx, my, button); + relationsComponent.mouseReleased(button); + configProfilesComponent.mouseReleased(button); + moduleComponent.mouseReleased(button); + } + + public void mouseScrolled(float mx, float my, double delta) { + if (!isInteractive()) return; + if (settingsPanel.mouseScrolled(mx, my, delta)) return; + if (settingsPanel.blocksMenuInput()) return; + if (category == Category.THEMES) { + themeComponent.scroll(mx, my, menuX, menuY, menuW, menuH, delta, S); + } else if (category == Category.MAIN_SETTINGS) { + mainSettingsComponent.scroll(mx, my, delta); + } else if (category == Category.RELATIONS) { + relationsComponent.scroll(mx, my, delta); + } else if (category == Category.CONFIGS) { + configProfilesComponent.scroll(mx, my, menuX, menuY, menuW, menuH, delta, S); + } else { + moduleComponent.scroll(mx, my, menuX, menuY, menuW, menuH, delta, S); + } + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (!isInteractive()) return false; + + if (settingsPanel.isActive() && settingsPanel.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + + if (isHintToggle(keyCode, modifiers)) { + MainConfig config = MainConfig.get(); + config.setClickGuiHintsEnabled(!config.isClickGuiHintsEnabled()); + return true; + } + + if (category == Category.MAIN_SETTINGS && mainSettingsComponent.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + + if (category == Category.CONFIGS && configProfilesComponent.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + + if (category == Category.RELATIONS && relationsComponent.keyPressed(keyCode, scanCode, modifiers)) { + return true; + } + + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + + if (ctrl && keyCode == GLFW.GLFW_KEY_F) { + ClickGuiSearch.setActive(true); + return true; + } + + if (ClickGuiSearch.isActive()) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + ClickGuiSearch.deactivate(); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { + ClickGuiSearch.backspace(); + return true; + } + + return true; + } + + return false; + } + + public boolean charTyped(char chr, int modifiers) { + if (!isInteractive()) return false; + if (category == Category.MAIN_SETTINGS && mainSettingsComponent.charTyped(chr, modifiers)) { + return true; + } + + if (category == Category.CONFIGS && configProfilesComponent.charTyped(chr, modifiers)) { + return true; + } + + if (category == Category.RELATIONS && relationsComponent.charTyped(chr, modifiers)) { + return true; + } + + if (ClickGuiSearch.isActive()) { + ClickGuiSearch.append(chr); + return true; + } + + return settingsPanel.charTyped(chr, modifiers); + } + + public boolean onFilesDrop(List paths) { + if (!isInteractive() || category != Category.CONFIGS) return false; + return configProfilesComponent.importDroppedFiles(paths); + } + + private void layout(float areaX, float areaY, float areaW, float areaH) { + menuW = MENU_W * S; + menuH = MENU_H * S; + menuX = areaX + (areaW - menuW) * 0.5f; + menuY = areaY + (areaH - menuH) * 0.5f; + + shellX = menuX - 20f * S; + shellY = menuY; + shellW = menuW + 40f * S; + shellH = menuH; + + categoryX = menuX - 20f * S; + categoryY = menuY + 40f * S; + searchX = menuX + 330f * S; + searchY = menuY + 7.5f * S; + } + + private boolean isInteractive() { + return openTarget && screenAnim >= INPUT_READY_PROGRESS; + } + + private RenderWarpStack.Scope pushLifecycleWarp(float progress) { + if (progress >= 0.999f) return Renderer2D.pushWarp(null); + return Renderer2D.pushPerspectiveWarp( + shellX, + shellY, + shellW, + shellH, + 0.0f, + 0.0f, + 0.0f, + 3.6f, + 0.80f, + 0.90f + 0.10f * progress + ); + } + + private void renderShellChrome(SettingsGuiPalette palette) { + float vLineX = shellX + 42.5f * S; + float hLineY = shellY + 28f * S; + float lineThickness = 0.5f * S; + float vLineH = shellH; + float hLineW = shellW - 43f * S; + float hSegW = 56f * S; + float vSegH = 52f * S; + float hTravel = Math.max(1f, hLineW - hSegW); + float vTravel = Math.max(1f, vLineH - vSegH); + float dt = AnimationUtility.deltaTime(); + + LayoutRender2D.rectQuad( + vLineX, shellY, lineThickness, vLineH, + palette.menuLineLow(), + palette.menuLineMid(), + palette.menuLineLow(), + palette.menuLineStrong() + ); + LayoutRender2D.rectQuad( + shellX + 43f * S, hLineY, hLineW, lineThickness, + palette.menuLineStrong(), + palette.menuLineLow(), + palette.menuLineStrong(), + palette.menuLineLow() + ); + + menuLineHSweep = AnimationUtility.clamp(menuLineHSweep + menuLineHDir * 120f * dt, 0f, hTravel); + if (menuLineHSweep >= hTravel) menuLineHDir = -1f; + else if (menuLineHSweep <= 0f) menuLineHDir = 1f; + + menuLineVSweep = AnimationUtility.clamp(menuLineVSweep + menuLineVDir * 95f * dt, 0f, vTravel); + if (menuLineVSweep >= vTravel) menuLineVDir = -1f; + else if (menuLineVSweep <= 0f) menuLineVDir = 1f; + + menuLinePulsePhase += 4.1f * dt; + float pulse = 0.65f + 0.35f * (float) Math.sin(menuLinePulsePhase); + + float hSegX = shellX + 43f * S + menuLineHSweep; + int hA0 = LayoutRender2D.alpha(palette.menuSweepStart(), pulse); + int hA1 = LayoutRender2D.alpha(palette.menuSweepEnd(), pulse); + LayoutRender2D.rectQuad( + hSegX, hLineY, hSegW, lineThickness, + hA0, + hA1, + hA1, + hA0 + ); + + float vSegY = shellY + menuLineVSweep; + int vA0 = LayoutRender2D.alpha(palette.menuSweepStart(), pulse * 0.86f); + int vA1 = LayoutRender2D.alpha(palette.menuSweepEnd(), pulse * 0.93f); + LayoutRender2D.rectQuad( + vLineX, vSegY, lineThickness, vSegH, + vA0, + vA1, + vA0, + vA1 + ); + + if (category.iconToken()) { + TextRenderer icons = Fonts.renderer("Icons", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + float iconSize = 11.5f * S; + float textSize = 11.5f * S; + float baseX = shellX + 50f * S; + float baseY = shellY + 13.5f * S; + ClickGuiRenderer.drawText( + icons, + category.token(), + baseX, + baseY, + iconSize, + palette.menuHeaderText(), + false + ); + float iconW = ClickGuiRenderer.textWidth(icons, category.token(), iconSize); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + " " + category.title(), + baseX + iconW + 1.5f * S, + baseY, + textSize, + palette.menuHeaderText(), + false + ); + } else if (category.svgIcon()) { + float iconSize = 11.5f * S; + float textSize = 11.5f * S; + float baseX = shellX + 50f * S; + float baseY = shellY + 13.5f * S; + Renderer2D.COLOR.svg( + category.token(), + baseX, + baseY + 0.5f * S, + iconSize, + iconSize, + SvgRenderOptions.overrideColor(palette.menuHeaderText()) + ); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + " " + category.title(), + baseX + iconSize + 1.5f * S, + baseY, + textSize, + palette.menuHeaderText(), + false + ); + } else { + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + "| " + category.title(), + shellX + 50f * S, + shellY + 13.5f * S, + 11.5f * S, + palette.menuHeaderText(), + false + ); + } + } + + private void renderPanelShadow(SettingsGuiPalette palette) { + LayoutRender2D.roundedSoftShadow( + shellX, + shellY, + shellW, + shellH, + 8f * S, + 8f * S, + 0.018f, + palette.menuShadow() + ); + } + + private void toggleEntry(String id) { + MenuSettingsResolver.toggleEntry(id); + } + + private void openSettings(String id) { + MenuSettingsResolver.ResolvedSettings resolved = MenuSettingsResolver.resolveSettings(id); + if (resolved == null || resolved.settings().isEmpty()) return; + settingsPanel.open(resolved.getId(), resolved.title(), resolved.settings(), resolved.hudContext()); + } + + private void openThemeEditor(EditableClickGuiTheme editor) { + if (editor == null) return; + activeThemeEditor = editor; + themeEditorPreview.reset(); + settingsPanel.openEditor("theme:" + editor.id(), editor.title(), editor.buildSettings()); + } + + public enum Category { + HUD("HUD", "clickgui.settings.tab.hud", "HUD", IconKind.TEXT), + UI("UI", "clickgui.settings.tab.ui", "UI", IconKind.TEXT), + THEMES("G", "clickgui.settings.tab.themes", "Themes", IconKind.FONT), + MAIN_SETTINGS("columns-3-cog", "clickgui.settings.tab.main_settings", "Main Settings", IconKind.SVG), + RELATIONS("user-pen", "clickgui.settings.tab.relations", "Relations", IconKind.SVG), + CONFIGS("folder-cog", "clickgui.settings.tab.configs", "Configs", IconKind.SVG); + + private final String token; + private final String titleKey; + private final String fallbackTitle; + private final IconKind iconKind; + + Category(String token, String titleKey, String fallbackTitle, IconKind iconKind) { + this.token = token; + this.titleKey = titleKey; + this.fallbackTitle = fallbackTitle; + this.iconKind = iconKind; + } + + public String token() { + return token; + } + + public String title() { + return ClickGuiI18n.tr(titleKey, fallbackTitle); + } + + public boolean iconToken() { + return iconKind == IconKind.FONT; + } + + public boolean svgIcon() { + return iconKind == IconKind.SVG; + } + + public enum IconKind { + TEXT, + FONT, + SVG + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java new file mode 100644 index 0000000..ed6bd45 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/MenuSettingsResolver.java @@ -0,0 +1,287 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; + +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module.ModuleComponent; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingFactory; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.features.module.modules.visuals.DropESP; +import dev.loki.lovisual.features.module.modules.visuals.NameTags; +import dev.loki.lovisual.features.relations.PlayerRelations; +import dev.loki.lovisual.util.item.IllegalItemUtil; +import dev.loki.lovisual.util.item.RarityColorConfig; +import dev.loki.lovisual.util.item.TopEnchantUtil; + +import java.util.ArrayList; +import java.util.List; + +public enum MenuSettingsResolver { + ; + private static final String UI_HOTBAR = "ui_hotbar"; + private static final String UI_HEALTH = "ui_health"; + private static final String UI_DYNAMIC_ISLAND = "ui_dynamic_island"; + private static final String UI_TAB_LIST = "ui_tab_list"; + private static final String UI_BAR = "ui_bar"; + private static final String UI_BUTTONS = "ui_buttons"; + private static final String UI_TOOLTIPS = "ui_tooltips"; + private static final String UI_SWAP_TOOLTIP = "ui_swap_tooltip"; + private static final String UI_RELATIONS_COLOR = "ui_relations_color"; + private static final String UI_ITEMS_COLOR = "ui_items_color"; + + private static final List STATIC_UI_CARDS = List.of( + new StaticHudCard("vanilla_buttons", "Buttons"), + new StaticHudCard("vanilla_tooltips", "Tooltips"), + new StaticHudCard("swap_tooltip", "Swap Tooltip"), + new StaticHudCard("vanilla_hotbar", "Hotbar"), + new StaticHudCard("vanilla_bar", "Bar"), + new StaticHudCard("vanilla_health", "Health"), + new StaticHudCard("dynamic_island", "Dynamic Island"), + new StaticHudCard("tab_list", "Tab List") + ); + private static final SettingOwner RELATIONS_OWNER = + new StaticOwner("relations_color", () -> PlayerRelations.get().save()); + private static final SettingOwner ITEMS_OWNER = + new StaticOwner("items_color", MenuSettingsResolver::saveItemColorOwners); + + public static List buildCards(MenuScreen.Category category) { + List out = new ArrayList<>(); + switch (category) { + case HUD -> { + for (DraggableHudElement widget : DraggableHudElementRegistry.getWidgets()) { + if (widget == null) continue; + out.add(new ModuleComponent.CardEntry( + widget.getId(), + widget.getTitle(), + "", + "", + !widget.getSettingDefs().isEmpty(), + widget.isEnabled(), + true + )); + } + } + case UI -> { + for (StaticHudCard card : STATIC_UI_CARDS) { + ModuleComponent.CardEntry entry = uiStaticCard(card); + if (entry != null) { + out.add(entry); + } + } + out.add(uiReadOnlyCard(UI_RELATIONS_COLOR, "Relations Color")); + out.add(uiReadOnlyCard(UI_ITEMS_COLOR, "Items Color")); + } + } + return out; + } + + public static void toggleEntry(String id) { + if (id == null || id.isBlank()) return; + + + String mappedId = mapUiAlias(id); + if (!mappedId.equals(id)) { + toggleEntry(mappedId); + return; + } + + DraggableHudElement draggable = DraggableHudElementRegistry.getById(id); + if (draggable != null) { + draggable.setEnabled(!draggable.isEnabled()); + return; + } + + toggleStatic(id); + } + + public static ResolvedSettings resolveSettings(String id) { + if (id == null || id.isBlank()) return null; + + if (UI_RELATIONS_COLOR.equals(id)) { + return resolveRelationsColorSettings(); + } + if (UI_ITEMS_COLOR.equals(id)) { + return resolveItemsColorSettings(); + } + + String mappedId = mapUiAlias(id); + if (!mappedId.equals(id)) { + return resolveSettings(mappedId); + } + + DraggableHudElement draggable = DraggableHudElementRegistry.getById(id); + if (draggable != null) { + return new ResolvedSettings(id, draggable.getTitle(), buildSettingsFromDefs(draggable), true); + } + + AbstractHudElement staticHud = StaticHudElementRegistry.getById(id); + if (staticHud != null) { + return new ResolvedSettings(id, staticHud.getTitle(), buildSettingsFromDefs(staticHud), true); + } + + return null; + } + + private static ModuleComponent.CardEntry uiStaticCard(StaticHudCard card) { + if (card == null) return null; + AbstractHudElement staticHud = StaticHudElementRegistry.getById(card.staticId()); + if (staticHud == null) return null; + + String title = staticHud.getTitle() == null || staticHud.getTitle().isBlank() + ? card.fallbackTitle() + : staticHud.getTitle(); + + return new ModuleComponent.CardEntry( + staticHud.getId(), + title, + "", + "", + !staticHud.getSettingDefs().isEmpty(), + staticHud.isEnabled(), + true + ); + } + + private static ModuleComponent.CardEntry uiReadOnlyCard(String id, String title) { + return new ModuleComponent.CardEntry(id, title, "", "", true, false, false); + } + + private static String mapUiAlias(String id) { + return switch (id) { + case UI_HOTBAR -> "vanilla_hotbar"; + case UI_BAR -> "vanilla_bar"; + case UI_HEALTH -> "vanilla_health"; + case UI_DYNAMIC_ISLAND -> "dynamic_island"; + case UI_TAB_LIST -> "tab_list"; + case UI_BUTTONS -> "vanilla_buttons"; + case UI_TOOLTIPS -> "vanilla_tooltips"; + case UI_SWAP_TOOLTIP -> "swap_tooltip"; + default -> id; + }; + } + + private static ResolvedSettings resolveRelationsColorSettings() { + PlayerRelations relations = PlayerRelations.get(); + List defs = new ArrayList<>(); + defs.add(SettingDef.colorNoAlpha("default_color", relations.colorDefaultValue())); + defs.add(SettingDef.colorNoAlpha("friend_color", relations.colorFriendValue())); + defs.add(SettingDef.colorNoAlpha("enemy_color", relations.colorEnemyValue())); + defs.add(SettingDef.colorNoAlpha("staff_color", relations.colorStaffValue())); + defs.add(SettingDef.colorNoAlpha("bedwars_self_color", relations.colorBedwarsSelfValue())); + defs.add(SettingDef.colorNoAlpha("bedwars_enemy_color", relations.colorBedwarsEnemyValue())); + + List out = SettingFactory.fromDefs(defs); + for (Setting setting : out) { + setting.setParent(RELATIONS_OWNER); + setting.preflightI18n(); + } + if (out.isEmpty()) return null; + return new ResolvedSettings(UI_RELATIONS_COLOR, "Relations Color", out, false); + } + + private static ResolvedSettings resolveItemsColorSettings() { + RarityColorConfig rarity = RarityColorConfig.INSTANCE; + List defs = new ArrayList<>(); + defs.add(SettingDef.colorNoAlpha("illegal_item_color", IllegalItemUtil.illegalColorValue())); + defs.add(SettingDef.colorNoAlpha("top_enchant_color", TopEnchantUtil.topColorValue())); + defs.add(SettingDef.colorNoAlpha("rarity_common", rarity.commonValue())); + defs.add(SettingDef.colorNoAlpha("rarity_uncommon", rarity.uncommonValue())); + defs.add(SettingDef.colorNoAlpha("rarity_rare", rarity.rareValue())); + defs.add(SettingDef.colorNoAlpha("rarity_epic", rarity.epicValue())); + + List out = SettingFactory.fromDefs(defs); + for (Setting setting : out) { + setting.setParent(ITEMS_OWNER); + setting.preflightI18n(); + } + if (out.isEmpty()) return null; + return new ResolvedSettings(UI_ITEMS_COLOR, "Items Color", out, false); + } + + private static void saveItemColorOwners() { + ConfigSerializer.requestSave(RarityColorConfig.INSTANCE); + BetterChat chat = BetterChat.get(); + if (chat != null) { + chat.saveConfig(); + } + DropESP dropESP = Modules.get(DropESP.class); + if (dropESP != null) { + dropESP.saveConfig(); + } + if (Modules.get(NameTags.class) != null) { + Modules.get(NameTags.class).saveConfig(); + } + } + + private static void toggleStatic(String staticId) { + AbstractHudElement staticHud = StaticHudElementRegistry.getById(staticId); + if (staticHud == null) return; + staticHud.setEnabled(!staticHud.isEnabled()); + } + + private static List buildSettingsFromDefs(AbstractHudElement hud) { + List out = SettingFactory.fromDefs(hud.getSettingDefs()); + for (Setting setting : out) { + setting.setParent(hud); + setting.preflightI18n(); + } + return out; + } + + private static List buildSettingsFromDefs(DraggableHudElement hud) { + List out = SettingFactory.fromDefs(hud.getSettingDefs()); + for (Setting setting : out) { + setting.setParent(hud); + setting.preflightI18n(); + } + return out; + } + + private static String resolveTitle(String id) { + return switch (id) { + case UI_RELATIONS_COLOR -> "Relations Color"; + case UI_ITEMS_COLOR -> "Items Color"; + case "vanilla_buttons", UI_BUTTONS -> "Buttons"; + case "vanilla_tooltips", UI_TOOLTIPS -> "Tooltips"; + case "swap_tooltip", UI_SWAP_TOOLTIP -> "Swap Tooltip"; + case "vanilla_hotbar", UI_HOTBAR -> "Hotbar"; + case "vanilla_bar", UI_BAR -> "Bar"; + case "vanilla_health", UI_HEALTH -> "Health"; + case "dynamic_island", UI_DYNAMIC_ISLAND -> "Dynamic Island"; + case "tab_list", UI_TAB_LIST -> "Tab List"; + default -> id; + }; + } + + private record StaticHudCard(String staticId, String fallbackTitle) { + } + + private record StaticOwner(String name, Runnable saveAction) implements SettingOwner { + + @Override + public void saveConfig() { + if (saveAction != null) { + saveAction.run(); + } + } + } + + public record ResolvedSettings(String id, String title, List settings, boolean hudContext) { + public String getId() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/SettingsGuiPalette.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/SettingsGuiPalette.java new file mode 100644 index 0000000..e313b16 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/SettingsGuiPalette.java @@ -0,0 +1,311 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; + +public final class SettingsGuiPalette { + private final Themes.Theme theme; + + private SettingsGuiPalette(Themes.Theme theme) { + this.theme = theme; + } + + public static SettingsGuiPalette current() { + return new SettingsGuiPalette(Theme.theme()); + } + + private static boolean isLegacy() { + return Themes.THEME_LEGACY.equals(Theme.currentId()); + } + + public static int withAlpha(int color, int alpha) { + int a = clamp(alpha); + return (color & 0x00FFFFFF) | ((a & 0xFF) << 24); + } + + public static int mix(int from, int to, float t) { + t = clamp01(t); + int a = Math.round(((from >>> 24) & 0xFF) * (1f - t) + ((to >>> 24) & 0xFF) * t); + int r = Math.round(((from >>> 16) & 0xFF) * (1f - t) + ((to >>> 16) & 0xFF) * t); + int g = Math.round(((from >>> 8) & 0xFF) * (1f - t) + ((to >>> 8) & 0xFF) * t); + int b = Math.round((from & 0xFF) * (1f - t) + (to & 0xFF) * t); + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + public static int darken(int color, float t) { + return mix(color, 0xFF000000, t); + } + + private static float clamp01(float v) { + if (v < 0f) return 0f; + return Math.min(v, 1f); + } + + private static int clamp(int v) { + if (v < 0) return 0; + return Math.min(v, 255); + } + + public int menuMaskLeft() { + if (isLegacy()) return 0xF5030406; + return withAlpha(darken(theme.windowBg(), 0.82f), 245); + } + + public int menuMaskRight() { + if (isLegacy()) return 0xF5050609; + return withAlpha(darken(theme.windowBg(), 0.76f), 245); + } + + public int menuWindowBgLeft() { + if (isLegacy()) return 0xCD121314; + return withAlpha(mix(theme.windowBg(), theme.surface(), 0.18f), 205); + } + + public int menuWindowBgRight() { + if (isLegacy()) return 0xCD000205; + return withAlpha(darken(mix(theme.windowBg(), theme.windowHeader(), 0.30f), 0.18f), 205); + } + + public int menuWindowStroke() { + if (isLegacy()) return 0xE1121314; + return withAlpha(mix(theme.windowStroke(), theme.surface(), 0.25f), 225); + } + + public int menuLineLow() { + if (isLegacy()) return 0x0F373746; + return withAlpha(mix(theme.strokeSoft(), theme.windowStroke(), 0.55f), 15); + } + + public int menuLineMid() { + if (isLegacy()) return 0x32373746; + return withAlpha(mix(theme.strokeSoft(), theme.windowStroke(), 0.55f), 50); + } + + public int menuLineStrong() { + if (isLegacy()) return 0xFA373746; + return withAlpha(mix(theme.strokeSoft(), theme.windowStroke(), 0.55f), 250); + } + + public int menuSweepStart() { + if (isLegacy()) return 0x235C6276; + return withAlpha(mix(theme.textMuted(), theme.strokeSoft(), 0.35f), 35); + } + + public int menuSweepEnd() { + if (isLegacy()) return 0x8C767C94; + return withAlpha(mix(theme.textPrimary(), theme.strokeSoft(), 0.28f), 140); + } + + public int menuHeaderText() { + if (isLegacy()) return 0xFFF5F5FF; + return withAlpha(theme.textPrimary(), 245); + } + + public int menuCategoryHoverLeft() { + if (isLegacy()) return 0x64373737; + return withAlpha(mix(theme.surface(), theme.strokeSoft(), 0.32f), 100); + } + + public int menuCategoryHoverRight() { + if (isLegacy()) return 0x64555564; + return withAlpha(mix(theme.surfaceHover(), theme.accentSoft(), 0.12f), 100); + } + + public int menuCategorySelectedLeft() { + if (isLegacy()) return 0x87151515; + return withAlpha(mix(theme.surface(), theme.windowHeader(), 0.34f), 135); + } + + public int menuCategorySelectedRight() { + if (isLegacy()) return 0x873D3D3D; + return withAlpha(mix(theme.surfaceHover(), theme.strokeSoft(), 0.22f), 135); + } + + public int menuCategoryText() { + if (isLegacy()) return 0xE0FFFFFF; + return withAlpha(theme.textPrimary(), 224); + } + + public int menuShadow() { + if (isLegacy()) return 0x5F000000; + return withAlpha(0xFF000000, 95); + } + + public int panelBgLeft() { + if (isLegacy()) return 0xAF121314; + return withAlpha(mix(theme.windowBg(), theme.surface(), 0.30f), 175); + } + + public int panelBgRight() { + if (isLegacy()) return 0xAF000205; + return withAlpha(darken(mix(theme.windowBg(), theme.windowHeader(), 0.42f), 0.25f), 175); + } + + public int panelStroke() { + if (isLegacy()) return 0xE1121314; + return withAlpha(mix(theme.windowStroke(), theme.strokeSoft(), 0.40f), 225); + } + + public int panelDivider() { + if (isLegacy()) return 0xFF303030; + return withAlpha(mix(theme.strokeSoft(), theme.windowStroke(), 0.50f), 255); + } + + public int panelText() { + if (isLegacy()) return 0xF5F5F5FF; + return withAlpha(theme.textPrimary(), 245); + } + + public int panelMuted() { + if (isLegacy()) return 0xA0A5A5A5; + return withAlpha(mix(theme.textMuted(), theme.textPrimary(), 0.12f), 160); + } + + public int panelShadow() { + if (isLegacy()) return 0x5F000000; + return withAlpha(0xFF000000, 95); + } + + public int panelBlurTint() { + if (isLegacy()) return 0xFF000000; + return darken(theme.windowBg(), 0.70f); + } + + public int panelPillBase() { + if (isLegacy()) return 0x4B1F1B23; + return withAlpha(mix(theme.surface(), theme.windowHeader(), 0.45f), 75); + } + + public int panelPillActive() { + if (isLegacy()) return 0xFF414141; + return withAlpha(mix(theme.surfaceHover(), theme.accentSoft(), 0.28f), 255); + } + + public int panelScrollTrackA() { + if (isLegacy()) return 0x4B232323; + return withAlpha(mix(theme.surface(), theme.windowBg(), 0.35f), 75); + } + + public int panelScrollTrackB() { + if (isLegacy()) return 0x5F2D2D2D; + return withAlpha(mix(theme.surfaceHover(), theme.windowBg(), 0.35f), 95); + } + + public int panelScrollHandleA() { + if (isLegacy()) return 0x4B2C2C2C; + return withAlpha(mix(theme.strokeSoft(), theme.surfaceHover(), 0.20f), 75); + } + + public int panelScrollHandleB() { + if (isLegacy()) return 0x5F656565; + return withAlpha(mix(theme.accentSoft(), theme.strokeSoft(), 0.45f), 95); + } + + public int moduleCardTop() { + if (isLegacy()) return 0x91171819; + return withAlpha(mix(theme.surface(), theme.windowHeader(), 0.30f), 145); + } + + public int moduleCardTopStrong() { + if (isLegacy()) return 0xFF131315; + return withAlpha(mix(theme.surface(), theme.surfaceHover(), 0.52f), 255); + } + + public int moduleCardBottom() { + if (isLegacy()) return 0x910A0C0F; + return withAlpha(darken(mix(theme.windowBg(), theme.surface(), 0.20f), 0.40f), 145); + } + + public int moduleCardBottomStrong() { + if (isLegacy()) return 0xFF131315; + return withAlpha(mix(theme.surface(), theme.windowHeader(), 0.46f), 255); + } + + public int moduleDividerStart() { + if (isLegacy()) return 0xB9191928; + return withAlpha(mix(theme.windowStroke(), theme.strokeSoft(), 0.35f), 185); + } + + public int moduleDividerEnd() { + if (isLegacy()) return 0xB937373C; + return withAlpha(mix(theme.strokeSoft(), theme.accentSoft(), 0.22f), 185); + } + + public int moduleTitleText() { + if (isLegacy()) return 0xFFFFFFFF; + return withAlpha(theme.textPrimary(), 255); + } + + public int moduleDescriptionText() { + if (isLegacy()) return 0xBA808080; + return withAlpha(theme.textMuted(), 186); + } + + public int moduleDescriptionIcon() { + if (isLegacy()) return 0xAA808080; + return withAlpha(mix(theme.textMuted(), theme.textPrimary(), 0.10f), 170); + } + + public int moduleBindBg0() { + if (isLegacy()) return 0x503D4347; + return withAlpha(mix(theme.surface(), theme.windowHeader(), 0.20f), 80); + } + + public int moduleBindBg1() { + if (isLegacy()) return 0x50474D51; + return withAlpha(mix(theme.surfaceHover(), theme.windowHeader(), 0.22f), 80); + } + + public int moduleBindBg2() { + if (isLegacy()) return 0x5051575B; + return withAlpha(mix(theme.surfaceHover(), theme.accentSoft(), 0.12f), 80); + } + + public int moduleBindBg3() { + if (isLegacy()) return 0x505B6165; + return withAlpha(mix(theme.surface(), theme.accentSoft(), 0.18f), 80); + } + + public int moduleBindStroke() { + if (isLegacy()) return 0xFF9B9BA5; + return withAlpha(mix(theme.strokeSoft(), theme.textMuted(), 0.42f), 255); + } + + public int moduleBindText() { + if (isLegacy()) return 0xFF878894; + return withAlpha(mix(theme.textMuted(), theme.textPrimary(), 0.28f), 255); + } + + public int moduleNoSettingsText() { + if (isLegacy()) return 0x966E707A; + return withAlpha(mix(theme.textMuted(), theme.textPrimary(), 0.15f), 150); + } + + public int moduleScrollTrackA() { + if (isLegacy()) return 0x4B232323; + return panelScrollTrackA(); + } + + public int moduleScrollTrackB() { + if (isLegacy()) return 0x5F2D2D2D; + return panelScrollTrackB(); + } + + public int moduleScrollHandleA() { + if (isLegacy()) return 0x4B2C2C2C; + return panelScrollHandleA(); + } + + public int moduleScrollHandleB() { + if (isLegacy()) return 0x5F656565; + return panelScrollHandleB(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/category/CategoryComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/category/CategoryComponent.java new file mode 100644 index 0000000..7d3d023 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/category/CategoryComponent.java @@ -0,0 +1,148 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.category; + +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.MenuScreen; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +public final class CategoryComponent { + private static final float PARALLAX_MAX_ANGLE = 5.5f; + private static final float PARALLAX_DEPTH = 2.8f; + private static final float PARALLAX_PERSPECTIVE = 1.0f; + private static final float PARALLAX_SCALE_BOOST = 0.014f; + private static final float PARALLAX_CONTENT_SHIFT = 1.0f; + + private final MenuScreen.Category category; + private float hoverAnim; + private float selectAnim; + + public CategoryComponent(MenuScreen.Category category) { + this.category = category; + } + + private static Parallax computeParallax(float mx, float my, float x, float y, float w, float h, float hover) { + float ease = smooth(Math.max(0f, Math.min(1f, hover))); + if (ease <= 0.001f || w <= 0f || h <= 0f) return Parallax.NONE; + float nx = clamp((mx - x) / w * 2f - 1f, -1f, 1f); + float ny = clamp((my - y) / h * 2f - 1f, -1f, 1f); + return new Parallax( + -nx * PARALLAX_MAX_ANGLE * ease, + ny * PARALLAX_MAX_ANGLE * ease, + 1f + PARALLAX_SCALE_BOOST * ease, + nx * PARALLAX_CONTENT_SHIFT * ease, + ny * PARALLAX_CONTENT_SHIFT * ease, + true + ); + } + + private static RenderWarpStack.Scope pushParallax(Parallax p, float x, float y, float w, float h) { + if (p == null || !p.active()) return Renderer2D.pushWarp(null); + return Renderer2D.pushPerspectiveWarp(x, y, w, h, p.yawDeg(), p.pitchDeg(), 0f, PARALLAX_DEPTH, PARALLAX_PERSPECTIVE, p.scale()); + } + + private static float smooth(float t) { + return t * t * (3f - 2f * t); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + public MenuScreen.Category category() { + return category; + } + + public void render(float x, float y, float mx, float my, MenuScreen.Category selected, float scale) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + float baseX = x + 5.25f * scale; + float baseY = y; + float tile = 20f * scale; + boolean selectedState = selected == category; + boolean hover = ClickGuiMath.insideRect(mx, my, baseX, baseY, tile, tile); + + hoverAnim = AnimationUtility.approach(hoverAnim, hover ? 1f : 0f, 0.25f); + selectAnim = AnimationUtility.approach(selectAnim, selectedState ? 1f : 0f, 0.25f); + + float scaled = 0.5f + selectAnim * 0.5f; + float cx = baseX + tile * 0.5f; + float cy = baseY + tile * 0.5f; + float sw = tile * scaled; + float sh = tile * scaled; + float sx = cx - sw * 0.5f; + float sy = cy - sh * 0.5f; + if (!selectedState && hoverAnim > 0.01f) { + LayoutRender2D.roundedQuad( + baseX, baseY, tile, tile, 4f * scale, + LayoutRender2D.alpha(palette.menuCategoryHoverLeft(), hoverAnim), + LayoutRender2D.alpha(palette.menuCategoryHoverRight(), hoverAnim), + LayoutRender2D.alpha(palette.menuCategoryHoverLeft(), hoverAnim), + LayoutRender2D.alpha(palette.menuCategoryHoverRight(), hoverAnim) + ); + } + + LayoutRender2D.roundedQuad( + sx, sy, sw, sh, 5f * scale, + LayoutRender2D.alpha(palette.menuCategorySelectedLeft(), selectAnim), + LayoutRender2D.alpha(palette.menuCategorySelectedRight(), selectAnim), + LayoutRender2D.alpha(palette.menuCategorySelectedRight(), selectAnim), + LayoutRender2D.alpha(palette.menuCategorySelectedLeft(), selectAnim) + ); + + float pcx = cx; + float pcy = cy; + + if (category.svgIcon()) { + float iconSize = 10.4f * scale; + Renderer2D.COLOR.svg( + category.token(), + pcx - iconSize * 0.5f, + pcy - iconSize * 0.5f, + iconSize, + iconSize, + SvgRenderOptions.overrideColor(LayoutRender2D.alpha(palette.menuCategoryText(), 0.9f + 0.1f * selectAnim)) + ); + return; + } + + TextRenderer font = category.iconToken() + ? Fonts.renderer("Icons", FontInfo.Type.Regular, ClickGuiRenderer.getInterMedium()) + : ClickGuiRenderer.getInterMedium(); + float size = category.iconToken() ? 8.7f * scale : (category.token().length() > 2 ? 7.6f * scale : 9.2f * scale); + float tw = ClickGuiRenderer.textWidth(font, category.token(), size); + float th = ClickGuiRenderer.textHeight(font, size); + ClickGuiRenderer.drawText( + font, + category.token(), + pcx - tw * 0.5f, + pcy - th * 0.5f, + size, + LayoutRender2D.alpha(palette.menuCategoryText(), 0.9f + 0.1f * selectAnim), + false + ); + } + + public boolean click(float x, float y, float mx, float my, float scale) { + float baseX = x + 5.25f * scale; + float baseY = y; + return ClickGuiMath.insideRect(mx, my, baseX, baseY, 20f * scale, 20f * scale); + } + + private record Parallax(float yawDeg, float pitchDeg, float scale, float shiftX, float shiftY, boolean active) { + private static final Parallax NONE = new Parallax(0f, 0f, 1f, 0f, 0f, false); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java new file mode 100644 index 0000000..a00ca5e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/main/MainSettingsComponent.java @@ -0,0 +1,454 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main; + +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingFactory; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderContext; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderSurface; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +public final class MainSettingsComponent { + private static final float PARALLAX_MAX_ANGLE = 4.0f; + private static final float PARALLAX_DEPTH = 2.2f; + private static final float PARALLAX_PERSPECTIVE = 1.0f; + private static final float PARALLAX_SCALE_BOOST = 0.010f; + private static final float PARALLAX_CONTENT_SHIFT = 1.1f; + private final Map> settingsByCategory = new EnumMap<>(Category.class); + private final List rows = new ArrayList<>(); + private final List settingHits = new ArrayList<>(); + private final List categoryHits = new ArrayList<>(); + private final Map categoryHoverAnim = new EnumMap<>(Category.class); + private Category selected = Category.IMAGE; + private float scroll; + private float smoothedScroll; + private boolean draggingScrollbar; + private boolean scrollbarVisible; + private float scrollbarX; + private float scrollbarY; + private float scrollbarW; + private float scrollbarH; + private float scrollbarThumbY; + private float scrollbarThumbH; + private float scrollbarMaxScroll; + private float scrollbarDragOffset; + private float rightX; + private float rightY; + private float rightW; + private float rightH; + private float contentX; + private float contentY; + private float contentW; + private float contentH; + private float lastSettingScale = 1.0f; + public MainSettingsComponent() { + rebuildSettings(); + } + + private static List buildSettings(List defs, MainConfig owner) { + List out = SettingFactory.fromDefs(defs); + for (Setting setting : out) { + setting.setParent(owner); + setting.preflightI18n(); + } + return out; + } + + private static float settingScale(float menuScale) { + return Math.max(1.0f, menuScale * 0.66f); + } + + private static Parallax computeParallax(float mx, float my, float x, float y, float w, float h, float hover) { + float ease = smooth(Math.max(0f, Math.min(1f, hover))); + if (ease <= 0.001f || w <= 0f || h <= 0f) return Parallax.NONE; + float nx = clamp((mx - x) / w * 2f - 1f, -1f, 1f); + float ny = clamp((my - y) / h * 2f - 1f, -1f, 1f); + return new Parallax( + -nx * PARALLAX_MAX_ANGLE * ease, + ny * PARALLAX_MAX_ANGLE * ease, + 1f + PARALLAX_SCALE_BOOST * ease, + nx * PARALLAX_CONTENT_SHIFT * ease, + ny * PARALLAX_CONTENT_SHIFT * ease, + true + ); + } + + private static RenderWarpStack.Scope pushParallax(Parallax p, float x, float y, float w, float h) { + if (p == null || !p.active()) return Renderer2D.pushWarp(null); + return Renderer2D.pushPerspectiveWarp(x, y, w, h, p.yawDeg(), p.pitchDeg(), 0f, PARALLAX_DEPTH, PARALLAX_PERSPECTIVE, p.scale()); + } + + private static float smooth(float t) { + return t * t * (3f - 2f * t); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + public void resetScroll() { + scroll = 0f; + smoothedScroll = 0f; + draggingScrollbar = false; + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float scale) { + ensureSettings(); + lastSettingScale = settingScale(scale); + SettingsGuiPalette palette = SettingsGuiPalette.current(); + + float areaX = menuX + 31f * scale; + float areaY = menuY + 33f * scale; + float areaW = menuW - 42f * scale; + float areaH = menuH - 39f * scale; + float gap = 8f * scale; + float leftW = 112f * scale; + + float leftX = areaX; + float leftY = areaY; + float leftH = areaH; + rightX = leftX + leftW + gap; + rightY = areaY; + rightW = Math.max(1f, areaW - leftW - gap); + rightH = areaH; + + drawPanel(leftX, leftY, leftW, leftH, scale, palette); + drawPanel(rightX, rightY, rightW, rightH, scale, palette); + renderCategoryList(leftX, leftY, leftW, mx, my, scale, palette); + renderSettingsPanel(mx, my, scale, palette); + } + + public boolean mousePressedScrollbar(float mx, float my, int button) { + if (button != 0 || !isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollbarDragOffset = ClickGuiMath.insideRect(mx, my, scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH) + ? my - scrollbarThumbY + : scrollbarThumbH * 0.5f; + scrollToMouse(my); + return true; + } + + public boolean mouseClicked(float mx, float my, int button) { + if (button != 0 && button != 1) return false; + if (isScrollbarHovered(mx, my)) return true; + + for (CategoryHit hit : categoryHits) { + if (!ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + if (hit.category() != selected) { + selected = hit.category(); + resetScroll(); + } + return true; + } + + if (!ClickGuiMath.insideRect(mx, my, rightX, rightY, rightW, rightH)) return false; + if (ClickGuiMath.insideRect(mx, my, contentX, contentY, contentW, contentH)) { + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, lastSettingScale)) { + for (SettingHit hit : settingHits) { + if (!ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + hit.setting().mouseClicked(mx, my, button); + return true; + } + } + } + return true; + } + + public void mouseReleased(float mx, float my, int button) { + if (button == 0) draggingScrollbar = false; + List settings = settingsByCategory.get(selected); + if (settings == null) return; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, lastSettingScale)) { + for (Setting setting : settings) { + setting.mouseReleased(mx, my, button); + } + } + } + + public void scroll(float mx, float my, double amount) { + if (!ClickGuiMath.insideRect(mx, my, rightX, rightY, rightW, rightH)) return; + scroll += (float) (amount * 22f); + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return false; + } + + public boolean charTyped(char chr, int modifiers) { + return false; + } + + private void renderCategoryList(float x, float y, float w, float mx, float my, float scale, SettingsGuiPalette palette) { + categoryHits.clear(); + float pad = 7f * scale; + float rowH = 20f * scale; + float rowY = y + 9f * scale; + + Category[] categories = Category.values(); + for (int i = 0; i < categories.length; i++) { + Category category = categories[i]; + boolean active = category == selected; + boolean hover = ClickGuiMath.insideRect(mx, my, x + pad, rowY, w - pad * 2f, rowH); + float hoverAnim = AnimationUtility.approach(categoryHoverAnim.getOrDefault(category, 0f), hover ? 1f : 0f, 0.20f); + categoryHoverAnim.put(category, hoverAnim); + int bg = active + ? LayoutRender2D.alpha(palette.menuCategorySelectedRight(), 0.86f) + : hoverAnim > 0.01f ? LayoutRender2D.alpha(palette.menuCategoryHoverRight(), 0.72f * hoverAnim) : 0; + if (bg != 0) { + LayoutRender2D.roundedQuad(x + pad, rowY, w - pad * 2f, rowH, 5f * scale, bg, bg, bg, bg); + } + + if (i < categories.length - 1) { + renderCategorySeparator(x, rowY + rowH + 2f * scale, w, scale, palette); + } + + int text = active ? palette.menuHeaderText() : LayoutRender2D.alpha(palette.panelMuted(), 0.72f + 0.20f * hoverAnim); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterMedium(), + category.title, + x + 13f * scale, + rowY + 6f * scale, + 7f * scale, + text, + false + ); + categoryHits.add(new CategoryHit(category, x + pad, rowY, w - pad * 2f, rowH)); + rowY += 24f * scale; + } + } + + private void renderCategorySeparator(float x, float y, float w, float scale, SettingsGuiPalette palette) { + float sx = x + 12f * scale; + float sw = Math.max(1f, w - 24f * scale); + LayoutRender2D.rectQuad( + sx, + y, + sw, + 0.5f * scale, + LayoutRender2D.alpha(palette.menuLineLow(), 0.24f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.34f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.34f), + LayoutRender2D.alpha(palette.menuLineLow(), 0.24f) + ); + } + + private void renderSettingsPanel(float mx, float my, float scale, SettingsGuiPalette palette) { + buildRows(); + float pad = 8f * scale; + float titleY = rightY + 8f * scale; + float titleSize = 8f * scale; + int text = palette.menuHeaderText(); + + LayoutRender2D.rectQuad( + rightX + pad, + titleY + 11f * scale, + rightW - pad * 2f, + 0.5f * scale, + LayoutRender2D.alpha(palette.menuLineLow(), 0.55f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.82f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.82f), + LayoutRender2D.alpha(palette.menuLineLow(), 0.55f) + ); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), selected.title, rightX + pad, titleY, titleSize, text, false); + + contentX = rightX + pad; + contentY = rightY + 27f * scale; + contentW = Math.max(1f, rightW - pad * 2f - (needsScrollbar() ? 7f * scale : 0f)); + contentH = Math.max(1f, rightH - 35f * scale); + + float totalH = totalRowsHeight(); + float maxScroll = Math.max(0f, totalH - contentH); + updateScrollbarMetrics(maxScroll, totalH, scale); + if (draggingScrollbar) { + scrollToMouse(my); + } + scroll = ClickGuiMath.clamp(scroll, -maxScroll, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, draggingScrollbar ? 0.55f : 0.2f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, draggingScrollbar ? 0.01f : 0.05f); + + settingHits.clear(); + boolean clipped = ScissorFunction.pushRaw(contentX, contentY, contentW, contentH); + float y = contentY + smoothedScroll; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, lastSettingScale)) { + for (SettingRow row : rows) { + float h = row.height(); + if (h > 0.5f) { + settingHits.add(new SettingHit(row.setting(), contentX, y, contentW, h)); + if (y + h >= contentY - 1f * scale && y <= contentY + contentH + 1f * scale) { + row.setting().render(contentX, y, contentW, mx, my); + } + } + y += h + row.gap(); + } + } + if (clipped) ScissorFunction.pop(); + + renderScrollbar(scale, palette); + } + + private void buildRows() { + rows.clear(); + List settings = settingsByCategory.get(selected); + if (settings == null) return; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(SettingRenderSurface.SETTINGS, lastSettingScale)) { + for (Setting setting : settings) { + float anim = setting.updateVisibilityAnim(); + boolean targetVisible = setting.isVisibilityTargetVisible(); + if (!targetVisible && anim <= 0.01f) continue; + rows.add(new SettingRow(setting, anim, setting.getHeight() * anim, 6f * anim)); + } + } + } + + private float totalRowsHeight() { + float out = 0f; + for (SettingRow row : rows) { + out += row.height() + row.gap(); + } + return out; + } + + private boolean needsScrollbar() { + return scrollbarVisible; + } + + private void updateScrollbarMetrics(float maxScroll, float totalH, float scale) { + scrollbarVisible = maxScroll > 0f && totalH > contentH + 0.5f; + scrollbarMaxScroll = maxScroll; + if (!scrollbarVisible) { + draggingScrollbar = false; + scrollbarX = scrollbarY = scrollbarW = scrollbarH = scrollbarThumbY = scrollbarThumbH = 0f; + return; + } + + scrollbarW = 4f * scale; + scrollbarX = rightX + rightW - 10f * scale; + scrollbarY = contentY; + scrollbarH = contentH; + scrollbarThumbH = Math.max(18f * scale, scrollbarH * (contentH / Math.max(contentH, totalH))); + float ratio = scrollbarMaxScroll <= 0f ? 0f : (-smoothedScroll / scrollbarMaxScroll); + scrollbarThumbY = scrollbarY + (scrollbarH - scrollbarThumbH) * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private void renderScrollbar(float scale, SettingsGuiPalette palette) { + if (!scrollbarVisible) return; + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + if (isScrollbarHovered(mx, my) || draggingScrollbar) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + LayoutRender2D.roundedQuad( + scrollbarX, + scrollbarY, + scrollbarW, + scrollbarH, + 2f * scale, + palette.moduleScrollTrackA(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackA() + ); + LayoutRender2D.roundedQuad( + scrollbarX, + scrollbarThumbY, + scrollbarW, + scrollbarThumbH, + 2f * scale, + palette.moduleScrollHandleA(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleA() + ); + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 4f; + return ClickGuiMath.insideRect(mx, my, scrollbarX - pad, scrollbarY - pad, scrollbarW + pad * 2f, scrollbarH + pad * 2f); + } + + private void scrollToMouse(float my) { + if (!scrollbarVisible || scrollbarMaxScroll <= 0f) return; + float span = scrollbarH - scrollbarThumbH; + if (span <= 0.5f) return; + float thumbTop = AnimationUtility.clamp(my - scrollbarDragOffset, scrollbarY, scrollbarY + span); + float ratio = (thumbTop - scrollbarY) / span; + scroll = -scrollbarMaxScroll * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private void drawPanel(float x, float y, float w, float h, float scale, SettingsGuiPalette palette) { + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, + y, + w, + h, + 5f * scale, + palette.moduleCardTop(), + palette.moduleCardTopStrong(), + palette.moduleCardBottom(), + palette.moduleCardBottomStrong() + ); + LayoutRender2D.roundedStroke(x, y, w, h, 5f * scale, 0.5f * scale, palette.menuWindowStroke()); + } + + private void ensureSettings() { + if (!settingsByCategory.isEmpty()) return; + rebuildSettings(); + } + + private void rebuildSettings() { + settingsByCategory.clear(); + MainConfig config = MainConfig.get(); + settingsByCategory.put(Category.IMAGE, buildSettings(config.getImageSettingDefs(), config)); + settingsByCategory.put(Category.MISCELLANIOUS, buildSettings(config.getMiscellaneousSettingDefs(), config)); + settingsByCategory.put(Category.SECURITY, buildSettings(config.getSecuritySettingDefs(), config)); + settingsByCategory.put(Category.UTILITY, buildSettings(config.getUtilitySettingDefs(), config)); + } + + private enum Category { + IMAGE("Image"), + SECURITY("Security"), + UTILITY("Utility"), + MISCELLANIOUS("Miscellanious"); + + private final String title; + + Category(String title) { + this.title = title; + } + } + + private record SettingRow(Setting setting, float anim, float height, float gap) { + } + + private record SettingHit(Setting setting, float x, float y, float w, float h) { + } + + private record CategoryHit(Category category, float x, float y, float w, float h) { + } + + private record Parallax(float yawDeg, float pitchDeg, float scale, float shiftX, float shiftY, boolean active) { + private static final Parallax NONE = new Parallax(0f, 0f, 1f, 0f, 0f, false); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleComponent.java new file mode 100644 index 0000000..c90d1b1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleComponent.java @@ -0,0 +1,612 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module; + +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other.StatusRender; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class ModuleComponent { + private static final float PARALLAX_MAX_ANGLE = 4.6f; + private static final float PARALLAX_DEPTH = 4.8f; + private static final float PARALLAX_PERSPECTIVE = 0.92f; + private static final float PARALLAX_SCALE_BOOST = 0.012f; + private static final float PARALLAX_CONTENT_SHIFT = 0.0f; + private final List hits = new ArrayList<>(); + private final Map stateAnimById = new HashMap<>(); + private final Map hoverAnimById = new HashMap<>(); + private final Map statusRenderById = new HashMap<>(); + private float scroll; + private float smoothedScroll; + private boolean scrollbarVisible; + private boolean draggingScrollbar; + private float scrollbarX; + private float scrollbarY; + private float scrollbarW; + private float scrollbarH; + private float scrollbarThumbY; + private float scrollbarThumbH; + private float scrollbarMaxScroll; + private float scrollbarDragOffset; + + private static String normalizeDescription(String description) { + if (description == null || description.isBlank()) { + return ClickGuiI18n.tr("clickgui.settings.module.description_missing", "Module description is missing."); + } + return description; + } + + private static int brightenRgb(int color, int delta) { + int a = (color >>> 24) & 0xFF; + int r = Math.min(255, ((color >>> 16) & 0xFF) + delta); + int g = Math.min(255, ((color >>> 8) & 0xFF) + delta); + int b = Math.min(255, (color & 0xFF) + delta); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private static Parallax computeParallax(float mx, float my, float x, float y, float w, float h, float hover) { + float ease = smooth(Math.max(0f, Math.min(1f, hover))); + if (ease <= 0.001f || w <= 0f || h <= 0f) return Parallax.NONE; + float nx = clamp((mx - x) / w * 2f - 1f, -1f, 1f); + float ny = clamp((my - y) / h * 2f - 1f, -1f, 1f); + return new Parallax( + -nx * PARALLAX_MAX_ANGLE * ease, + ny * PARALLAX_MAX_ANGLE * ease, + 1f + PARALLAX_SCALE_BOOST * ease, + nx * PARALLAX_CONTENT_SHIFT * ease, + ny * PARALLAX_CONTENT_SHIFT * ease, + true + ); + } + + private static RenderWarpStack.Scope pushParallax(Parallax p, float x, float y, float w, float h) { + if (p == null || !p.active()) return Renderer2D.pushWarp(null); + return Renderer2D.pushPerspectiveWarp(x, y, w, h, p.yawDeg(), p.pitchDeg(), 0f, PARALLAX_DEPTH, PARALLAX_PERSPECTIVE, p.scale()); + } + + private static float smooth(float t) { + return t * t * (3f - 2f * t); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + public void resetScroll() { + scroll = 0f; + smoothedScroll = 0f; + draggingScrollbar = false; + } + + public void scroll(float mx, float my, float menuX, float menuY, float menuW, float menuH, double amount, float scale) { + float hitX = menuX + 35f * scale; + float hitY = menuY + 13f * scale; + float hitW = menuW - 35f * scale + 7f * scale; + float hitH = menuH - 13f * scale + 15f * scale; + if (!ClickGuiMath.insideRect(mx, my, hitX, hitY, hitW, hitH)) return; + scroll += (float) (amount * 20f); + } + + public void render(float menuX, + float menuY, + float menuW, + float menuH, + List entries, + float scale) { + hits.clear(); + if (entries.isEmpty()) return; + + int[] offsets = calculateOffsets(entries, scale); + int column = 0; + int maxScroll = 0; + float columnWidth = 142f * scale; + SettingsGuiPalette palette = SettingsGuiPalette.current(); + + float scissorX = menuX + 35f * scale - 75f * scale; + float scissorY = menuY + 35.5f * scale; + float scissorW = menuW - 35f * scale + 150f * scale; + float scissorBottom = menuY + menuH - 3.5f * scale; + float scissorH = Math.max(1f, scissorBottom - scissorY); + boolean clipped = ScissorFunction.pushRaw(scissorX, scissorY, scissorW, scissorH); + + for (int i = entries.size() - 1; i >= 0; i--) { + CardEntry entry = entries.get(i); + int componentHeight = Math.round(getComponentHeight(entry, columnWidth + 40f * scale, scale) + 9f * scale); + + float x = menuX + 32f * scale + (column * (columnWidth + 48f * scale)); + float y = menuY + 35f * scale + offsets[column] - componentHeight + smoothedScroll; + float w = columnWidth + 40f * scale; + float h = componentHeight - 9f * scale; + + if (y + h > scissorY + 1.0f * scale && scissorBottom - 1.0f * scale > y) { + CardHit hit = drawCard(entry, x, y, w, h, scale, palette); + hits.add(hit); + } + + offsets[column] -= componentHeight; + maxScroll = Math.max(maxScroll, offsets[column]); + column = (column + 1) % 2; + } + + int clamped = ClickGuiMath.clamp(Math.round(maxScroll - (menuH * 0.5f + 35f * scale)), 0, maxScroll); + updateScrollbarMetrics(menuX, menuY, menuH, clamped, scale); + if (draggingScrollbar) { + scrollToMouse(ClickGuiRenderer.getMouseY()); + } + scroll = ClickGuiMath.clamp(scroll, -clamped, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, draggingScrollbar ? 0.55f : 0.2f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, draggingScrollbar ? 0.01f : 0.05f); + if (clipped) ScissorFunction.pop(); + + renderScrollbar(menuX, menuY, menuH, clamped, scale, palette); + } + + public boolean mousePressedScrollbar(float mx, float my, int button) { + if (button != 0) return false; + if (!isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollbarDragOffset = ClickGuiMath.insideRect(mx, my, scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH) + ? my - scrollbarThumbY + : scrollbarThumbH * 0.5f; + scrollToMouse(my); + return true; + } + + public void mouseReleased(int button) { + if (button == 0) { + draggingScrollbar = false; + } + } + + public CardAction click(float mx, float my, int button) { + if (isScrollbarHovered(mx, my)) return null; + for (CardHit hit : hits) { + if (!ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + if (button == 1 && !hit.hasSettings()) continue; + return new CardAction( + hit.getId(), + button == 0, + button == 1, + hit.statusHit(mx, my), + hit.bindHit(mx, my), + hit.toggleable() + ); + } + return null; + } + + private CardHit drawCard(CardEntry entry, float x, float y, float w, float h, float scale, SettingsGuiPalette palette) { + float enabledAnim = stateAnimById.compute(entry.getId(), (k, v) -> AnimationUtility.approach(v == null ? 0f : v, entry.enabled() ? 1f : 0f, 0.16f)); + boolean hovered = ClickGuiMath.insideRect(ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY(), x, y, w, h); + float hoverAnim = hoverAnimById.compute(entry.getId(), (k, v) -> AnimationUtility.approach(v == null ? 0f : v, hovered ? 1f : 0f, 0.18f)); + Parallax parallax = computeParallax(ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY(), x, y, w, h, hoverAnim); + int brightnessOffset = Math.round(9f * enabledAnim); + int alphaOffset = 150 + Math.round(105f * enabledAnim); + String description = normalizeDescription(entry.description()); + float descHeight = descriptionHeight(description, w, scale); + int cardTopStrong = brightenRgb(palette.moduleCardTopStrong(), brightnessOffset); + int cardBottomStrong = brightenRgb(palette.moduleCardBottomStrong(), brightnessOffset); + BindRect bindRect; + float statusX = x + w - 24f * scale; + float statusY = y + descHeight + 31f * scale; + + ClickGuiRenderer.drawRoundedRectShadow( + x, + y + 1.5f * scale, + w, + h, + 5.4f * scale, + 9.0f * scale, + 1.0f * scale, + LayoutRender2D.alpha(0xFF000000, 0.22f + 0.10f * hoverAnim) + ); + try (var ignored = pushParallax(parallax, x, y, w, h)) { + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, y, w, h, + 5f * scale, + palette.moduleCardTop(), + cardTopStrong, + palette.moduleCardBottom(), + cardBottomStrong + ); + LayoutRender2D.rectQuad( + x, y + descHeight + 25f * scale, w, 0.5f * scale, + palette.moduleDividerStart(), + palette.moduleDividerEnd(), + palette.moduleDividerEnd(), + palette.moduleDividerStart() + ); + Renderer2D.COLOR.radialGlowMasked( + x, + y, + w, + h, + 5f * scale, + 0f, + 48f * scale, + x + w * 0.18f, + y + h * 0.08f, + LayoutRender2D.alpha(palette.moduleCardTopStrong(), 0.12f + 0.10f * hoverAnim) + ); + + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + "• " + entry.title(), + x + 8.8f * scale, + y + 5.4f * scale, + 13f * scale, + SettingsGuiPalette.withAlpha(palette.moduleTitleText(), alphaOffset), + false + ); + + drawDescription(description, x, y, w, scale, palette); + if (!entry.hasSettings()) { + drawNoSettingsHint(x, y, descHeight, scale, palette); + } + bindRect = drawBind(entry, x, y, w, descHeight, scale, palette); + if (entry.toggleable()) { + StatusRender status = statusRenderById.computeIfAbsent(entry.getId(), k -> new StatusRender()); + status.render(statusX, statusY, enabledAnim, scale); + } + } + + return new CardHit( + entry.getId(), + x, + y, + w, + h, + bindRect.hitX(), + bindRect.hitY(), + bindRect.hitW(), + bindRect.hitH(), + statusX, + statusY, + 16f * scale, + 8f * scale, + entry.hasSettings(), + entry.toggleable() + ); + } + + private void drawDescription(String description, float x, float y, float w, float scale, SettingsGuiPalette palette) { + float maxWidth = w - 25f * scale; + float currentX = x + 10f * scale; + float currentY = y + 19f * scale; + String[] words = description.split(" "); + StringBuilder line = new StringBuilder(); + int currentLine = 1; + float textSize = 10f * scale; + TextRenderer icons = Fonts.renderer("Icons", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + int iconColor = palette.moduleDescriptionIcon(); + int textColor = palette.moduleDescriptionText(); + + for (String word : words) { + float wordWidth = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), word + " ", textSize); + if (currentX + wordWidth > x + maxWidth) { + if (currentLine == 1) { + ClickGuiRenderer.drawText( + icons, + "J", + x + 6.5f * scale, + currentY + 0.75f * scale, + 9f * scale, + iconColor, + false + ); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + line.toString(), + x + 15f * scale, + currentY, + textSize, + textColor, + false + ); + } else { + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + line.toString(), + x + 5f * scale, + currentY, + textSize, + textColor, + false + ); + } + line = new StringBuilder(); + currentY += ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterRegular(), textSize) - 5.75f * scale; + currentX = x + 10f * scale; + currentLine++; + } + line.append(word).append(" "); + currentX += wordWidth; + } + + if (!line.isEmpty()) { + if (currentLine == 1) { + ClickGuiRenderer.drawText( + icons, + "J", + x + 6.5f * scale, + currentY + 0.75f * scale, + 9f * scale, + iconColor, + false + ); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + line.toString(), + x + 15f * scale, + currentY, + textSize, + textColor, + false + ); + } else { + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + line.toString(), + x + 7f * scale, + currentY, + textSize, + textColor, + false + ); + } + } + } + + private BindRect drawBind(CardEntry entry, float x, float y, float w, float descHeight, float scale, SettingsGuiPalette palette) { + String label = entry.bindLabel(); + if (label == null || label.isBlank()) { + return new BindRect(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f); + } + float labelW = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), label, 12f * scale); + float bindX = x + w - 37.5f * scale - labelW; + float bindY = y + descHeight + 29.75f * scale; + float bindW = labelW + 6f * scale; + float bindH = 10f * scale; + float hitY = y + descHeight + 32.5f * scale; + float hitH = 9f * scale; + + LayoutRender2D.roundedQuad( + bindX + 0.25f * scale, bindY, bindW, bindH, 3f * scale, + palette.moduleBindBg0(), + palette.moduleBindBg1(), + palette.moduleBindBg2(), + palette.moduleBindBg3() + ); + LayoutRender2D.roundedStroke( + bindX + 0.25f * scale, bindY, bindW, bindH, 3f * scale, 0.5f * scale, + palette.moduleBindStroke() + ); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + label, + x + w - 34.5f * scale - labelW, + y + descHeight + 34f * scale, + 12f * scale, + palette.moduleBindText(), + false + ); + return new BindRect(bindX, bindY, bindW, bindH, bindX, hitY, bindW, hitH); + } + + private void drawNoSettingsHint(float x, float y, float descHeight, float scale, SettingsGuiPalette palette) { + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + ClickGuiI18n.tr("clickgui.settings.module.no_settings", "No settings"), + x + 10f * scale, + y + descHeight + 33.5f * scale, + 9f * scale, + palette.moduleNoSettingsText(), + false + ); + } + + private void renderScrollbar(float menuX, float menuY, float menuH, int clamped, float scale, SettingsGuiPalette palette) { + updateScrollbarMetrics(menuX, menuY, menuH, clamped, scale); + if (!scrollbarVisible) return; + + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + boolean hover = isScrollbarHovered(mx, my); + if (hover || draggingScrollbar) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + + LayoutRender2D.roundedQuad( + scrollbarX, scrollbarY, scrollbarW, scrollbarH, 2f * scale, + palette.moduleScrollTrackA(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackA() + ); + + LayoutRender2D.roundedQuad( + scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH, 2f * scale, + palette.moduleScrollHandleA(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleA() + ); + } + + private void updateScrollbarMetrics(float menuX, float menuY, float menuH, int clamped, float scale) { + scrollbarVisible = clamped > 0; + scrollbarMaxScroll = Math.max(0f, clamped); + if (!scrollbarVisible) { + draggingScrollbar = false; + scrollbarX = scrollbarY = scrollbarW = scrollbarH = scrollbarThumbY = scrollbarThumbH = 0f; + return; + } + + float offsetY = 14f * scale; + scrollbarW = 4f * scale; + scrollbarX = menuX + 400f * scale - 35f * scale - scrollbarW + 50f * scale; + scrollbarY = menuY + offsetY + 22f * scale; + scrollbarH = menuH - offsetY * 2f - 14f * scale; + + float contentHeight = scrollbarMaxScroll; + float viewHeight = menuH - offsetY * 2f; + scrollbarThumbH = Math.max(20f * scale, viewHeight * (viewHeight / (contentHeight + viewHeight))); + float scrollRatio = contentHeight <= 0f ? 0f : (-smoothedScroll / contentHeight); + scrollbarThumbY = scrollbarY + (scrollbarH - scrollbarThumbH) * AnimationUtility.clamp(scrollRatio, 0f, 1f); + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 4f; + return ClickGuiMath.insideRect(mx, my, scrollbarX - pad, scrollbarY - pad, scrollbarW + pad * 2f, scrollbarH + pad * 2f); + } + + private void scrollToMouse(float my) { + if (!scrollbarVisible || scrollbarMaxScroll <= 0f) return; + float span = scrollbarH - scrollbarThumbH; + if (span <= 0.5f) return; + float thumbTop = AnimationUtility.clamp(my - scrollbarDragOffset, scrollbarY, scrollbarY + span); + float ratio = (thumbTop - scrollbarY) / span; + scroll = -scrollbarMaxScroll * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private float descriptionHeight(String description, float w, float scale) { + float maxWidth = w - 25f * scale; + float currentX = 0f; + int lineCount = 1; + for (String word : description.split(" ")) { + float wordW = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), word + " ", 10f * scale); + if (currentX + wordW > maxWidth) { + lineCount++; + currentX = 0f; + } + currentX += wordW; + } + float lineH = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterRegular(), 10f * scale); + float compactStep = Math.max(scale, lineH - 5.75f * scale); + return Math.max(0f, lineH - 4f * scale + (lineCount - 1) * compactStep); + } + + private int getComponentHeight(CardEntry entry, float w, float scale) { + return Math.round(45f * scale + descriptionHeight(normalizeDescription(entry.description()), w, scale)); + } + + private int[] calculateOffsets(List entries, float scale) { + int[] offsets = new int[2]; + int column = 0; + for (int i = entries.size() - 1; i >= 0; i--) { + int componentHeight = Math.round(getComponentHeight(entries.get(i), 182f * scale, scale) + 9f * scale); + offsets[column] += componentHeight; + column = (column + 1) % 2; + } + return offsets; + } + + public record CardEntry(String id, + String title, + String description, + String bindLabel, + boolean hasSettings, + boolean enabled, + boolean toggleable, + boolean shownInModuleList, + List searchAliases) { + public CardEntry { + searchAliases = searchAliases == null ? List.of() : List.copyOf(searchAliases); + } + + public CardEntry(String id, + String title, + String description, + String bindLabel, + boolean hasSettings, + boolean enabled, + boolean toggleable, + boolean shownInModuleList) { + this(id, title, description, bindLabel, hasSettings, enabled, toggleable, shownInModuleList, List.of()); + } + + public CardEntry(String id, + String title, + String description, + String bindLabel, + boolean hasSettings, + boolean enabled, + boolean toggleable) { + this(id, title, description, bindLabel, hasSettings, enabled, toggleable, true, List.of()); + } + + public String getId() { + return id; + } + } + + public record CardAction(String id, + boolean leftClick, + boolean rightClick, + boolean statusClick, + boolean bindClick, + boolean toggleable) { + public String getId() { + return id; + } + } + + private record Parallax(float yawDeg, float pitchDeg, float scale, float shiftX, float shiftY, boolean active) { + private static final Parallax NONE = new Parallax(0f, 0f, 1f, 0f, 0f, false); + } + + private record BindRect(float x, float y, float w, float h, float hitX, float hitY, float hitW, float hitH) { + } + + private record CardHit(String id, + float x, + float y, + float w, + float h, + float bindX, + float bindY, + float bindW, + float bindH, + float statusX, + float statusY, + float statusW, + float statusH, + boolean hasSettings, + boolean toggleable) { + public String getId() { + return id; + } + + boolean statusHit(float mx, float my) { + if (!toggleable) return false; + return ClickGuiMath.insideRect(mx, my, statusX, statusY, statusW, statusH); + } + + boolean bindHit(float mx, float my) { + return ClickGuiMath.insideRect(mx, my, bindX, bindY, bindW, bindH); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleSettingsWindow.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleSettingsWindow.java new file mode 100644 index 0000000..3f60088 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/ModuleSettingsWindow.java @@ -0,0 +1,12 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module; + +public final class ModuleSettingsWindow { + // Visual-only iteration: this class is intentionally empty until settings layer integration. +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/SettingsPanelComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/SettingsPanelComponent.java new file mode 100644 index 0000000..def65e7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/module/SettingsPanelComponent.java @@ -0,0 +1,703 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.module; + +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import net.minecraft.client.Minecraft; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.settings.Setting; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderContext; +import dev.loki.lovisual.features.gui.clickgui.settings.SettingRenderSurface; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiHintOverlay; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; + +import java.util.ArrayList; +import java.util.List; + +public final class SettingsPanelComponent { + private static final float DROPDOWN_PANEL_W = 115.0f; + private static final float DROPDOWN_PANEL_H = 240.0f; + private static final float DROPDOWN_HEADER_H = 24.0f; + private static final float DROPDOWN_SEPARATOR_H = 4.0f; + private static final float DROPDOWN_RADIUS = 10.0f; + private final List settings = new ArrayList<>(); + private final List rows = new ArrayList<>(); + private final List hits = new ArrayList<>(); + private final SettingRenderSurface renderSurface; + private final Minecraft mc = Minecraft.getInstance(); + private String targetId = ""; + private String title = "Settings"; + private boolean hudContext = false; + private boolean editorContext = false; + private boolean openTarget = false; + private float openAnim = 0f; + private float panelHeight = 120f; + private float scroll = 0f; + private float smoothedScroll = 0f; + private float closeHoverAnim = 0f; + private boolean dragging = false; + private float dragOffsetX = 0f; + private float dragOffsetY = 0f; + private boolean manualPos = false; + private float manualX = 0f; + private float manualY = 0f; + private boolean limitedHeight = false; + private float hudExtraTop = 0f; + private float panelX; + private float panelY; + private float panelW; + private float panelH; + private float closeX; + private float closeY; + private float closeW; + private float closeH; + private float contentX; + private float contentY; + private float contentW; + private float contentH; + private float maxScroll; + private float contentPadLeft; + private float contentPadRight; + private float pillX; + private float pillY; + private float pillW; + private float pillH; + private float pillActiveAnim = 1f; + private HudPreviewMode hudPreviewMode = HudPreviewMode.ONLY_CURRENT; + private float lastSettingScale = 1.0f; + public SettingsPanelComponent() { + this(SettingRenderSurface.MODULES); + } + public SettingsPanelComponent(SettingRenderSurface renderSurface) { + this.renderSurface = renderSurface == null ? SettingRenderSurface.SETTINGS : renderSurface; + } + + private static boolean inside(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static boolean isHintToggle(int keyCode, int modifiers) { + return keyCode == GLFW.GLFW_KEY_H && (modifiers & GLFW.GLFW_MOD_ALT) != 0; + } + + private static int mixColor(int from, int to, float t) { + t = Math.max(0f, Math.min(1f, t)); + int a = (int) (((from >>> 24) & 0xFF) * (1 - t) + ((to >>> 24) & 0xFF) * t); + int r = (int) (((from >>> 16) & 0xFF) * (1 - t) + ((to >>> 16) & 0xFF) * t); + int g = (int) (((from >>> 8) & 0xFF) * (1 - t) + ((to >>> 8) & 0xFF) * t); + int b = (int) (((from) & 0xFF) * (1 - t) + ((to) & 0xFF) * t); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + public void open(String id, String title, List source, boolean hudContext) { + if (id == null) return; + resetHudPreview(); + this.targetId = id; + this.title = title == null || title.isBlank() ? id : title; + this.settings.clear(); + if (source != null) this.settings.addAll(source); + this.hudContext = hudContext; + this.editorContext = false; + this.openTarget = true; + this.scroll = 0f; + this.smoothedScroll = 0f; + this.closeHoverAnim = 0f; + this.dragging = false; + this.rows.clear(); + this.hits.clear(); + if (hudContext) { + this.manualPos = false; + } + if (hudContext) { + this.hudPreviewMode = HudPreviewMode.ONLY_CURRENT; + this.pillActiveAnim = 1f; + applyHudPreviewMode(); + } + } + + public void openEditor(String id, String title, List source) { + open(id, title, source, false); + this.editorContext = true; + this.manualPos = false; + } + + public void close() { + openTarget = false; + dragging = false; + } + + public boolean isActive() { + return openTarget || openAnim > 0.001f; + } + + public boolean shouldMaskMenu() { + return (hudContext || editorContext) && isActive(); + } + + public boolean blocksMenuInput() { + return (hudContext || editorContext) && (openTarget || openAnim > 0.35f); + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float scale) { + float dt = AnimationUtility.deltaTime(); + float openTargetValue = openTarget ? 1f : 0f; + openAnim = AnimationUtility.approach(openAnim, openTargetValue, dt, openTarget ? 12f : 13f); + openAnim = AnimationUtility.snap(openAnim, openTargetValue, 0.01f); + float pillTarget = hudPreviewMode == HudPreviewMode.ALL_ENABLED ? 0f : 1f; + pillActiveAnim = AnimationUtility.approach(pillActiveAnim, pillTarget, dt, 14f); + pillActiveAnim = AnimationUtility.snap(pillActiveAnim, pillTarget, 0.002f); + + if (!openTarget && openAnim <= 0.001f) { + openAnim = 0f; + if (hudContext) { + resetHudPreview(); + hudContext = false; + } + editorContext = false; + return; + } + + float panelScale = panelScale(scale); + lastSettingScale = settingScale(panelScale); + buildRows(panelScale); + + float baseW = DROPDOWN_PANEL_W * panelScale; + float baseX = menuX + menuW + 24f * panelScale; + float baseY = menuY + 4f * panelScale; + if (manualPos) { + baseX = manualX; + baseY = manualY; + } + hudExtraTop = hudContext ? 20f * panelScale : 0f; + float targetH = AnimationUtility.clamp( + totalRowsHeight() + (DROPDOWN_HEADER_H + DROPDOWN_SEPARATOR_H) * panelScale + 5f * panelScale + hudExtraTop, + 0f, + DROPDOWN_PANEL_H * panelScale + ); + panelHeight = AnimationUtility.approach(panelHeight, targetH, dt, 14f); + panelHeight = AnimationUtility.snap(panelHeight, targetH, 0.25f); + + float distanceProgress = AnimationUtility.easeInOutCubic(openAnim); + float fadeProgress = AnimationUtility.easeInOutCubic(openAnim); + if (dragging) { + manualPos = true; + manualX = mx + dragOffsetX; + manualY = my + dragOffsetY; + panelX = manualX; + panelY = manualY; + } else { + panelX = baseX; + panelY = baseY; + } + panelW = baseW; + panelH = panelHeight; + limitedHeight = panelH >= (DROPDOWN_PANEL_H * panelScale - 0.25f * panelScale); + SettingsGuiPalette palette = SettingsGuiPalette.current(); + + try (RenderWarpStack.Scope lifecycleScope = pushLifecycleWarp(distanceProgress)) { + int bgTl = LayoutRender2D.alpha(palette.panelBgLeft(), fadeProgress); + int bgTr = LayoutRender2D.alpha(palette.panelBgRight(), fadeProgress); + int bgBr = LayoutRender2D.alpha(SettingsGuiPalette.darken(palette.panelBgRight(), 0.08f), fadeProgress); + int bgBl = LayoutRender2D.alpha(SettingsGuiPalette.darken(palette.panelBgLeft(), 0.05f), fadeProgress); + int stroke = LayoutRender2D.alpha(palette.panelStroke(), fadeProgress); + int line = LayoutRender2D.alpha(palette.panelDivider(), 0.52f * fadeProgress); + int text = LayoutRender2D.alpha(palette.panelText(), fadeProgress); + int mutedBase = LayoutRender2D.alpha(palette.panelMuted(), fadeProgress); + + String header = title; + closeW = 16f * panelScale; + closeH = 14f * panelScale; + closeX = panelX + panelW - closeW - 6f * panelScale; + closeY = panelY + (DROPDOWN_HEADER_H * panelScale - closeH) * 0.5f; + boolean closeHover = inside(mx, my, closeX, closeY, closeW, closeH); + closeHoverAnim = AnimationUtility.approach(closeHoverAnim, closeHover ? 1f : 0f, dt, 10f); + int muted = mixColor(mutedBase, text, closeHoverAnim * 0.7f); + + renderMatteChrome(header, panelScale, bgTl, bgTr, bgBr, bgBl, stroke, line, text, muted, palette); + + float contentStartY = panelY + (DROPDOWN_HEADER_H + DROPDOWN_SEPARATOR_H) * panelScale; + if (hudContext) { + renderHudModePill(panelScale, text, muted, palette); + contentStartY += hudExtraTop; + } + + contentPadLeft = 6f * panelScale; + contentPadRight = 13f * panelScale; + float contentClipInset = 2.2f * panelScale; + contentX = panelX + contentPadLeft; + contentY = contentStartY + contentClipInset; + contentW = Math.max(1f, panelW - contentPadLeft - contentPadRight); + contentH = Math.max(0f, panelY + panelH - contentY - 7.0f * panelScale); + + maxScroll = Math.max(0f, totalRowsHeight() - contentH); + scroll = AnimationUtility.clamp(scroll, -maxScroll, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, dt, 14f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, 0.1f); + + hits.clear(); + float rowsReveal = fadeProgress; + boolean clipped = ScissorFunction.pushRaw(contentX, contentY, contentW, contentH); + + float y = contentY + smoothedScroll; + for (SettingRow row : rows) { + float sh = row.height() * rowsReveal; + float gap = row.gap() * rowsReveal; + if (sh > 0.5f) { + hits.add(new SettingHit(row.setting(), contentX, y, contentW, sh, row.scale())); + if (y + sh >= contentY - 1.0f * panelScale && y <= contentY + contentH + 1.0f * panelScale) { + float slide = (1f - row.anim()) * 6f * panelScale; + boolean itemClip = ScissorFunction.pushRaw(contentX, y, contentW, sh); + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, row.scale())) { + row.setting().render(contentX, y + slide, contentW, mx, my); + } + ClickGuiRenderer.flushRenderer(); + if (itemClip) { + ScissorFunction.pop(); + } + } + } + y += sh + gap; + } + + ClickGuiRenderer.flushRenderer(); + if (clipped) { + ScissorFunction.pop(); + } + + renderScrollbar(panelScale, palette); + } + renderHudEditorHints(menuX, menuY, menuW, menuH, panelScale, fadeProgress); + renderSettingsPanelHints(menuX, menuY, menuW, menuH, panelScale, fadeProgress); + } + + public boolean mouseClicked(float mx, float my, int button, float scale) { + if (!isActive()) return false; + if (openAnim <= 0.01f) return false; + float panelScale = panelScale(scale); + + if (button == 0 && inside(mx, my, closeX, closeY, closeW, closeH)) { + close(); + return true; + } + + if (!inside(mx, my, panelX, panelY, panelW, panelH)) { + return hudContext || editorContext; + } + + if (button == 0 && inside(mx, my, panelX, panelY, panelW, DROPDOWN_HEADER_H * panelScale)) { + dragging = true; + dragOffsetX = panelX - mx; + dragOffsetY = panelY - my; + return true; + } + + if (hudContext && button == 0 && inside(mx, my, pillX, pillY, pillW, pillH)) { + float half = pillW * 0.5f; + hudPreviewMode = (mx < pillX + half) ? HudPreviewMode.ALL_ENABLED : HudPreviewMode.ONLY_CURRENT; + applyHudPreviewMode(); + return true; + } + + if (inside(mx, my, contentX, contentY, contentW, contentH)) { + SettingHit target = null; + for (SettingHit hit : hits) { + if (inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) { + target = hit; + break; + } + } + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, target == null ? settingScale(panelScale) : target.scale())) { + for (Setting setting : settings) { + if (target != null && target.setting() == setting) continue; + setting.mouseClickedOutside(mx, my, button); + } + if (target != null) { + if (!target.setting().isAvailable()) { + return true; + } + target.setting().mouseClicked(mx, my, button); + return true; + } + } + } else { + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, settingScale(panelScale))) { + for (Setting setting : settings) setting.mouseClickedOutside(mx, my, button); + } + } + return true; + } + + public boolean mouseReleased(float mx, float my, int button) { + if (!isActive()) return false; + if (button == 0) dragging = false; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, lastSettingScale)) { + for (Setting setting : settings) { + if (!setting.isAvailable()) continue; + setting.mouseReleased(mx, my, button); + } + } + return inside(mx, my, panelX, panelY, panelW, panelH); + } + + public boolean mouseScrolled(float mx, float my, double amount) { + if (!isActive()) return false; + if (!limitedHeight) { + return inside(mx, my, panelX, panelY, panelW, panelH); + } + if (!inside(mx, my, panelX, panelY, panelW, panelH)) { + return hudContext || editorContext; + } + for (SettingHit hit : hits) { + if (!inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + if (!hit.setting().isAvailable()) return true; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, hit.scale())) { + if (hit.setting().mouseScrolled(mx, my, amount)) return true; + } + break; + } + scroll += (float) (amount * 20f * Math.max(0.25f, lastSettingScale)); + return true; + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (!isActive()) return false; + if (isHintToggle(keyCode, modifiers)) { + MainConfig config = MainConfig.get(); + if (hudContext) { + config.setClickGuiHudEditorHintsEnabled(!config.isClickGuiHudEditorHintsEnabled()); + } else { + config.setClickGuiHintsEnabled(!config.isClickGuiHintsEnabled()); + } + return true; + } + + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, lastSettingScale)) { + for (Setting setting : settings) { + if (!setting.isAvailable()) continue; + if (setting.keyPressed(keyCode, scanCode, modifiers)) return true; + } + } + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + close(); + return true; + } + return false; + } + + public boolean charTyped(char chr, int modifiers) { + if (!isActive()) return false; + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, lastSettingScale)) { + for (Setting setting : settings) { + if (!setting.isAvailable()) continue; + if (setting.charTyped(chr, modifiers)) return true; + } + } + return false; + } + + private void renderHudModePill(float scale, int text, int muted, SettingsGuiPalette palette) { + updateHudModePillBounds(scale); + + float dt = AnimationUtility.deltaTime(); + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + float segmentW = pillW * 0.5f; + boolean enabledHover = inside(mx, my, pillX, pillY, segmentW, pillH); + boolean onlyHover = inside(mx, my, pillX + segmentW, pillY, segmentW, pillH); + int baseA = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgLeft(), 0.32f), openAnim); + int baseB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgRight(), 0.40f), openAnim); + int strokeA = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerStart(), 0.32f), openAnim); + int strokeB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), palette.menuCategorySelectedRight(), 0.20f), openAnim); + + ClickGuiRenderer.drawBlur(pillX, pillY, pillW, pillH, 5.5f * scale, palette.panelBlurTint(), (105f / 255f) * openAnim); + LayoutRender2D.roundedQuad(pillX, pillY, pillW, pillH, 5.5f * scale, baseA, baseB, SettingsGuiPalette.darken(baseB, 0.05f), baseA); + LayoutRender2D.roundedStrokeQuad(pillX, pillY, pillW, pillH, 5.5f * scale, 0.45f * scale, strokeA, strokeB, LayoutRender2D.alpha(strokeB, 0.82f), LayoutRender2D.alpha(strokeA, 0.88f)); + + float innerPad = 1.3f * scale; + float activeW = segmentW - innerPad * 2f; + float activeH = pillH - innerPad * 2f; + float pillT = AnimationUtility.easeInOutCubic(pillActiveAnim); + float activeX = pillX + innerPad + (segmentW * pillT); + int activeA = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedLeft(), 0.18f), openAnim); + int activeB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedRight(), 0.24f), openAnim); + int activeC = LayoutRender2D.alpha(SettingsGuiPalette.darken(activeB, 0.10f), openAnim); + LayoutRender2D.roundedSoftShadow(activeX, pillY + innerPad, activeW, activeH, 4.4f * scale, 4.0f * scale, 0.018f, LayoutRender2D.alpha(palette.menuShadow(), openAnim * 0.55f)); + LayoutRender2D.roundedQuad(activeX, pillY + innerPad, activeW, activeH, 4.4f * scale, activeA, activeB, activeC, activeA); + LayoutRender2D.roundedStrokeQuad(activeX, pillY + innerPad, activeW, activeH, 4.4f * scale, 0.28f * scale, LayoutRender2D.alpha(palette.moduleDividerStart(), openAnim * 0.86f), LayoutRender2D.alpha(palette.moduleDividerEnd(), openAnim * 0.92f), LayoutRender2D.alpha(palette.moduleDividerEnd(), openAnim * 0.72f), LayoutRender2D.alpha(palette.moduleDividerStart(), openAnim * 0.78f)); + + float sepX = pillX + segmentW; + LayoutRender2D.roundedQuad(sepX - 0.42f * scale, pillY + 4.0f * scale, 0.84f * scale, pillH - 8.0f * scale, 0.42f * scale, + LayoutRender2D.alpha(palette.moduleDividerStart(), 0.35f * openAnim), + LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.50f * openAnim), + LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.42f * openAnim), + LayoutRender2D.alpha(palette.moduleDividerStart(), 0.28f * openAnim)); + + float ty = pillY + (pillH - ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), 6.2f * scale)) * 0.5f; + int enabledColor = mixColor(mixColor(text, muted, pillT), text, enabledHover ? 0.10f : 0f); + int onlyColor = mixColor(mixColor(muted, text, pillT), text, onlyHover ? 0.10f : 0f); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), "Enabled", pillX + 8.0f * scale, ty, 6.2f * scale, enabledColor, false); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), "Only this", pillX + segmentW + 8.0f * scale, ty, 6.2f * scale, onlyColor, false); + } + + private void updateHudModePillBounds(float scale) { + pillX = panelX + 7f * scale; + pillY = panelY + DROPDOWN_HEADER_H * scale + 4f * scale; + pillW = panelW - 14f * scale; + pillH = 16f * scale; + } + + private void renderScrollbar(float scale, SettingsGuiPalette palette) { + if (!limitedHeight || maxScroll <= 0f) return; + + float trackW = 2.5f * scale; + float trackX = panelX + panelW - 5f * scale; + float trackY = contentY + 5f * scale; + float viewableH = Math.max(1f, contentH); + float trackH = Math.max(8f * scale, viewableH - 10f * scale); + + float rowsH = Math.max(1f, totalRowsHeight()); + float handleH = Math.min(trackH, Math.max(20f * scale, (viewableH / rowsH) * trackH)); + float ratio = maxScroll <= 0f ? 0f : (-smoothedScroll / maxScroll); + float handleY = trackY + (trackH - handleH) * ratio; + + int trTl = LayoutRender2D.alpha(palette.panelScrollTrackA(), openAnim); + int trTr = LayoutRender2D.alpha(palette.panelScrollTrackB(), openAnim); + int hdTl = LayoutRender2D.alpha(palette.panelScrollHandleA(), openAnim); + int hdTr = LayoutRender2D.alpha(palette.panelScrollHandleB(), openAnim); + + LayoutRender2D.roundedQuad(trackX, trackY, trackW, trackH, 1.25f * scale, trTl, trTr, trTr, trTl); + LayoutRender2D.roundedQuad(trackX, handleY, trackW, handleH, 1.25f * scale, hdTl, hdTr, hdTr, hdTl); + } + + private void renderHudEditorHints(float fallbackX, float fallbackY, float fallbackW, float fallbackH, float scale, float alpha) { + MainConfig config = MainConfig.get(); + if (!hudContext || !config.isClickGuiHudEditorHintsEnabled()) return; + + float areaX = 0f; + float areaY = 0f; + float areaW = Math.max(1f, fallbackX + fallbackW); + float areaH = Math.max(1f, fallbackY + fallbackH); + if (mc != null && mc.getWindow() != null) { + int screenW = mc.getWindow().getWidth(); + int screenH = mc.getWindow().getHeight(); + areaW = Math.max(1f, HudScale.virtualWidth(screenW, screenH)); + areaH = Math.max(1f, HudScale.virtualHeight(screenW, screenH)); + } + + ClickGuiHintOverlay.renderBottomLeft( + areaX, + areaY, + areaW, + areaH, + scale, + alpha, + ClickGuiI18n.tr("clickgui.hints.hud.alt", "Alt - drag without widget linking"), + ClickGuiI18n.tr("clickgui.hints.hud.close", "Esc - close editor"), + ClickGuiI18n.tr("clickgui.hints.hud.hide", "Alt+H - hide hints") + ); + } + + + private void renderSettingsPanelHints(float fallbackX, float fallbackY, float fallbackW, float fallbackH, float scale, float alpha) { + MainConfig config = MainConfig.get(); + if (hudContext || !isActive() || !config.isClickGuiHintsEnabled()) return; + + float areaX = 0f; + float areaY = 0f; + float areaW = Math.max(1f, fallbackX + fallbackW); + float areaH = Math.max(1f, fallbackY + fallbackH); + if (mc != null && mc.getWindow() != null) { + int screenW = mc.getWindow().getWidth(); + int screenH = mc.getWindow().getHeight(); + areaW = Math.max(1f, HudScale.virtualWidth(screenW, screenH)); + areaH = Math.max(1f, HudScale.virtualHeight(screenW, screenH)); + } + + ClickGuiHintOverlay.renderBottomLeft( + areaX, + areaY, + areaW, + areaH, + scale, + alpha, + ClickGuiI18n.tr("clickgui.hints.settings_panel.change", "LMB - change focused setting"), + ClickGuiI18n.tr("clickgui.hints.settings_panel.drag", "Drag header - move this panel"), + ClickGuiI18n.tr("clickgui.hints.settings_panel.scroll", "Wheel - scroll settings"), + ClickGuiI18n.tr("clickgui.hints.settings_panel.back", "Esc - back to category"), + ClickGuiI18n.tr("clickgui.hints.settings_panel.hide", "Alt+H - hide hints") + ); + } + + private void renderMatteChrome(String header, + float scale, + int bgTl, + int bgTr, + int bgBr, + int bgBl, + int stroke, + int line, + int text, + int muted, + SettingsGuiPalette palette) { + if (panelW <= 0.5f || panelH <= 0.5f || openAnim <= 0.001f) return; + + float radius = DROPDOWN_RADIUS * scale; + float headerBottom = panelY + DROPDOWN_HEADER_H * scale; + int shadow = LayoutRender2D.alpha(palette.panelShadow(), 0.72f * openAnim); + int blurTint = LayoutRender2D.alpha(palette.panelBlurTint(), openAnim); + int topWashA = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelBgLeft(), palette.menuCategorySelectedLeft(), 0.16f), openAnim); + int topWashB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelBgRight(), palette.menuCategorySelectedRight(), 0.18f), openAnim); + int bodyA = LayoutRender2D.alpha(SettingsGuiPalette.mix(bgTl, palette.menuWindowBgLeft(), 0.18f), openAnim); + int bodyB = LayoutRender2D.alpha(SettingsGuiPalette.mix(bgTr, palette.menuWindowBgRight(), 0.18f), openAnim); + int bodyC = LayoutRender2D.alpha(SettingsGuiPalette.darken(bgBr, 0.06f), openAnim); + int accent = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedRight(), 0.18f), 0.55f * openAnim); + int accentSoft = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillActive(), palette.panelBgRight(), 0.42f), 0.22f * openAnim); + int strokeTop = LayoutRender2D.alpha(SettingsGuiPalette.mix(stroke, palette.moduleDividerEnd(), 0.22f), openAnim); + int strokeBottom = LayoutRender2D.alpha(SettingsGuiPalette.mix(stroke, palette.panelBgRight(), 0.22f), 0.82f * openAnim); + + LayoutRender2D.roundedSoftShadow(panelX - 1.4f * scale, panelY + 1.2f * scale, panelW + 2.8f * scale, panelH + 2.4f * scale, radius + 1.6f * scale, 9.5f * scale, 0.028f, shadow); + ClickGuiRenderer.drawBlur(panelX, panelY, panelW, panelH, radius, blurTint, (150f / 255f) * openAnim); + LayoutRender2D.roundedQuad(panelX, panelY, panelW, panelH, radius, bodyA, bodyB, bodyC, bgBl); + LayoutRender2D.roundedStrokeQuad(panelX, panelY, panelW, panelH, radius, 0.58f * scale, strokeTop, LayoutRender2D.alpha(palette.moduleDividerEnd(), openAnim * 0.72f), strokeBottom, LayoutRender2D.alpha(strokeTop, 0.82f)); + + LayoutRender2D.roundedQuad(panelX + 1.0f * scale, panelY + 1.0f * scale, panelW - 2.0f * scale, DROPDOWN_HEADER_H * scale - 1.0f * scale, Math.max(0f, radius - 1.0f * scale), topWashA, topWashB, LayoutRender2D.alpha(SettingsGuiPalette.darken(topWashB, 0.18f), 0.64f), LayoutRender2D.alpha(SettingsGuiPalette.darken(topWashA, 0.14f), 0.64f)); + LayoutRender2D.rectQuad(panelX + 8.0f * scale, headerBottom, panelW - 16.0f * scale, Math.max(0.45f, 0.55f * scale), LayoutRender2D.alpha(line, 0.55f), line, LayoutRender2D.alpha(line, 0.28f), LayoutRender2D.alpha(line, 0.28f)); + LayoutRender2D.rectQuad(panelX + 8.0f * scale, headerBottom + 0.6f * scale, panelW - 16.0f * scale, Math.max(0.45f, 0.65f * scale), accent, accentSoft, 0x00000000, 0x00000000); + + float markW = 2.4f * scale; + float markH = 9.2f * scale; + LayoutRender2D.roundedQuad(panelX + 8.0f * scale, panelY + 7.2f * scale, markW, markH, markW * 0.5f, accent, accentSoft, accentSoft, accent); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), header, panelX + 14.0f * scale, panelY + 7.0f * scale, 8.0f * scale, text, false); + + renderCloseButton(scale, text, muted, palette); + } + + private void renderCloseButton(float scale, int text, int muted, SettingsGuiPalette palette) { + int bgA = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverLeft(), 0.16f + closeHoverAnim * 0.26f); + int bgB = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverRight(), 0.18f + closeHoverAnim * 0.28f); + int bgC = SettingsGuiPalette.mix(SettingsGuiPalette.darken(palette.panelBgRight(), 0.06f), palette.menuCategoryHoverRight(), 0.12f + closeHoverAnim * 0.20f); + int strokeA = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerStart(), 0.26f), openAnim * (0.72f + closeHoverAnim * 0.22f)); + int strokeB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), palette.menuCategorySelectedRight(), 0.22f), openAnim * (0.78f + closeHoverAnim * 0.20f)); + float radius = closeH * 0.50f; + + ClickGuiRenderer.drawBlur(closeX, closeY, closeW, closeH, radius, palette.panelBlurTint(), (72f / 255f) * openAnim); + LayoutRender2D.roundedSoftShadow(closeX, closeY + 0.4f * scale, closeW, closeH, radius, 4.0f * scale, 0.018f + closeHoverAnim * 0.018f, LayoutRender2D.alpha(palette.menuShadow(), openAnim * (0.38f + closeHoverAnim * 0.36f))); + LayoutRender2D.roundedQuad(closeX, closeY, closeW, closeH, radius, LayoutRender2D.alpha(bgA, openAnim), LayoutRender2D.alpha(bgB, openAnim), LayoutRender2D.alpha(bgC, openAnim), LayoutRender2D.alpha(bgA, openAnim)); + LayoutRender2D.roundedStrokeQuad(closeX, closeY, closeW, closeH, radius, 0.42f * scale, strokeA, strokeB, LayoutRender2D.alpha(strokeB, 0.74f), LayoutRender2D.alpha(strokeA, 0.78f)); + + float icon = 6.8f * scale; + int iconColor = LayoutRender2D.alpha(SettingsGuiPalette.mix(muted, text, 0.42f + closeHoverAnim * 0.48f), openAnim); + Renderer2D.COLOR.svg("x", closeX + (closeW - icon) * 0.5f, closeY + (closeH - icon) * 0.5f, icon, icon, SvgRenderOptions.overrideColor(iconColor)); + if (closeHoverAnim > 0.08f) SystemCursor.set(SystemCursor.CursorType.HAND); + } + + private void buildRows(float menuScale) { + rows.clear(); + for (Setting setting : settings) { + float rowScale = settingScale(setting, menuScale); + try (SettingRenderContext.Scope ignored = SettingRenderContext.push(renderSurface, rowScale)) { + float anim = setting.updateVisibilityAnim(); + boolean targetVisible = setting.isVisibilityTargetVisible(); + if (!targetVisible && anim <= 0.01f) continue; + float h = setting.getHeight() * anim; + float gap = 0f; + rows.add(new SettingRow(setting, anim, h, gap, rowScale)); + } + } + } + + private float totalRowsHeight() { + float out = 0f; + for (SettingRow row : rows) { + out += row.height() + row.gap(); + } + return out; + } + + private void applyHudPreviewMode() { + if (!hudContext) return; + DraggableHudElement target = DraggableHudElementRegistry.getById(targetId); + if (hudPreviewMode == HudPreviewMode.ONLY_CURRENT) { + if (target != null) { + target.setPreviewEnabled(true); + } + DraggableHudElementRegistry.setEditorWidgetId(targetId); + DraggableHudElementRegistry.setForceVisible(true); + } else { + if (target != null) { + target.setPreviewEnabled(false); + } + DraggableHudElementRegistry.setEditorWidgetId(null); + DraggableHudElementRegistry.setForceVisible(true); + } + } + + private void resetHudPreview() { + DraggableHudElement target = DraggableHudElementRegistry.getById(targetId); + if (target != null) { + target.setPreviewEnabled(false); + } + DraggableHudElementRegistry.setEditorWidgetId(null); + DraggableHudElementRegistry.setForceVisible(false); + } + + private float panelScale(float menuScale) { + float base = Math.max(0.25f, menuScale); + if (renderSurface != SettingRenderSurface.MODULES) return base; + return AnimationUtility.clamp(base * 1.5f, 2.55f, 3.0f); + } + + private float settingScale(float menuScale) { + return Math.max(0.25f, menuScale); + } + + private float settingScale(Setting setting, float menuScale) { + return settingScale(menuScale); + } + + private RenderWarpStack.Scope pushLifecycleWarp(float progress) { + if (progress >= 0.999f || dragging) { + return Renderer2D.pushWarp(null); + } + return Renderer2D.pushPerspectiveWarp( + panelX, + panelY, + panelW, + panelH, + 0.0f, + 0.0f, + 0.0f, + 3.6f, + 0.80f, + 0.92f + 0.08f * progress + ); + } + + private enum HudPreviewMode { + ALL_ENABLED, + ONLY_CURRENT + } + + private record SettingRow(Setting setting, float anim, float height, float gap, float scale) { + } + + private record SettingHit(Setting setting, float x, float y, float w, float h, float scale) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/BackgroundComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/BackgroundComponent.java new file mode 100644 index 0000000..7ba2787 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/BackgroundComponent.java @@ -0,0 +1,30 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * Portions derived from ThunderHack Recode, copyright (c) 2023-2024 Pan4ur & 06ED. + * Upstream: https://github.com/Pan4ur/ThunderHack-Recode + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; + +public final class BackgroundComponent { + + public void render(float x, float y, float w, float h, float scale) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + ClickGuiRenderer.drawBlur(x, y, w, h, 8f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, y, w, h, 8f * scale, + palette.menuWindowBgLeft(), + palette.menuWindowBgRight(), + palette.menuWindowBgRight(), + palette.menuWindowBgLeft() + ); + LayoutRender2D.roundedStroke(x, y, w, h, 8f * scale, 0.1f * scale, palette.menuWindowStroke()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/CategoryContainerComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/CategoryContainerComponent.java new file mode 100644 index 0000000..497a00a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/CategoryContainerComponent.java @@ -0,0 +1,74 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.MenuScreen; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.category.CategoryComponent; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; + +import java.util.ArrayList; +import java.util.List; + +public final class CategoryContainerComponent { + private final List categoryComponents = new ArrayList<>(); + + public CategoryContainerComponent() { + for (MenuScreen.Category category : MenuScreen.Category.values()) { + categoryComponents.add(new CategoryComponent(category)); + } + } + + public void render(float x, float y, float mx, float my, MenuScreen.Category selected, float scale) { + float offset = 0f; + for (CategoryComponent component : categoryComponents) { + component.render(x + 6f * scale, y + offset, mx, my, selected, scale); + offset += 29f * scale; + if (component.category() == MenuScreen.Category.THEMES) { + renderSeparator(x, y + offset + 5f * scale, scale); + offset += 10f * scale; + } else if (component.category() == MenuScreen.Category.RELATIONS) { + renderSeparator(x, y + offset - 4f * scale, scale); + offset += 10f * scale; + } + } + } + + public MenuScreen.Category click(float x, float y, float mx, float my, float scale) { + float offset = 0f; + for (CategoryComponent component : categoryComponents) { + if (component.click(x + 6f * scale, y + offset, mx, my, scale)) { + return component.category(); + } + offset += 29f * scale; + if (component.category() == MenuScreen.Category.THEMES + || component.category() == MenuScreen.Category.RELATIONS) { + offset += 10f * scale; + } + } + return null; + } + + private void renderSeparator(float x, float y, float scale) { + SettingsGuiPalette palette = SettingsGuiPalette.current(); + float sx = x + 8f * scale; + float sy = y; + float sw = 26f * scale; + float sh = 0.5f * scale; + LayoutRender2D.rectQuad( + sx, + sy, + sw, + sh, + LayoutRender2D.alpha(palette.menuLineLow(), 0.65f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.72f), + LayoutRender2D.alpha(palette.menuLineStrong(), 0.72f), + LayoutRender2D.alpha(palette.menuLineLow(), 0.65f) + ); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/SearchComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/SearchComponent.java new file mode 100644 index 0000000..35f3ab1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/SearchComponent.java @@ -0,0 +1,81 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiSearch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; + +public final class SearchComponent { + public void render(float x, float y, float w, float h) { + boolean typing = ClickGuiSearch.isActive(); + String text = ClickGuiSearch.getText(); + float scale = w / 80f; + + ClickGuiRenderer.drawBlur(x, y, w, h, 3f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, y, w, h, 3f * scale, + LayoutRender2D.argb(155, 18, 19, 20), + LayoutRender2D.argb(155, 5, 6, 7), + LayoutRender2D.argb(155, 5, 6, 7), + LayoutRender2D.argb(155, 18, 19, 20) + ); + LayoutRender2D.roundedStroke(x, y, w, h, 3f * scale, 0.5f * scale, LayoutRender2D.argb(225, 18, 19, 20)); + + float dividerX = x + 65.5f * scale; + LayoutRender2D.rect(dividerX, y + 4f * scale, 0.5f * scale, h - 8f * scale, LayoutRender2D.argb(55, 155, 155, 155)); + + String display = text.isEmpty() && !typing ? "Search" : text; + float tx = x + 4f * scale; + float ts = 12f * scale; + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterRegular(), ts); + float ty = y + (h - th) * 0.5f - 0.5f * scale; + boolean clipped = ScissorFunction.pushRaw(x + scale, y, w - 16f * scale, h); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + display, + tx, + ty, + ts, + typing ? 0xFFFFFFFF : 0xFF878894, + false + ); + if (typing && (System.currentTimeMillis() % 1000L < 500L)) { + float cw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), text, ts); + float caretY = ty + Math.max(0f, (th - 7f * scale) * 0.5f); + ClickGuiRenderer.drawRect(tx + cw + 0.5f * scale, caretY, 0.5f * scale, 7f * scale, 0xFFFFFFFF); + } + if (clipped) ScissorFunction.pop(); + + TextRenderer icons = Fonts.renderer("Icons", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + float iconSize = 8f * scale; + String icon = "s"; + float iconW = ClickGuiRenderer.textWidth(icons, icon, iconSize); + float iconH = ClickGuiRenderer.textHeight(icons, iconSize); + float iconX = dividerX + (w - (dividerX - x) - iconW) * 0.5f; + float iconY = y + (h - iconH) * 0.5f - 0.35f * scale; + int iconColor = typing ? 0xFFF5F5FF : 0xFF878894; + ClickGuiRenderer.drawText(icons, icon, iconX, iconY, iconSize, iconColor, false); + } + + public boolean click(float x, float y, float w, float h, float mx, float my, int button) { + if (button != 0) return false; + boolean inside = ClickGuiMath.insideRect(mx, my, x, y, w, h); + if (inside) { + ClickGuiSearch.setActive(true); + return true; + } + ClickGuiSearch.deactivate(); + return false; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/StatusRender.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/StatusRender.java new file mode 100644 index 0000000..1dee416 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/other/StatusRender.java @@ -0,0 +1,36 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; + +public final class StatusRender { + private float stateAnim; + + public void render(float x, float y, float targetState, float scale) { + stateAnim = AnimationUtility.approach(stateAnim, targetState, 0.22f); + + float w = 16f * scale; + float h = 8f * scale; + float knob = 9f * scale; + float knobX = x + 8f * scale * stateAnim - 0.5f * scale; + float knobY = y - 0.5f * scale; + + LayoutRender2D.rounded(x, y, w, h, 4f * scale, LayoutRender2D.argb(40, 128, 128, 128)); + LayoutRender2D.rounded(x, y, w, h, 4f * scale, LayoutRender2D.argb(Math.round(100f * stateAnim), 128, 128, 128)); + LayoutRender2D.roundedQuad( + knobX, knobY, knob, knob, 4.5f * scale, + LayoutRender2D.argb(255, 61, 67, 71), + LayoutRender2D.argb(255, 71, 77, 81), + LayoutRender2D.argb(255, 81, 87, 91), + LayoutRender2D.argb(255, 91, 97, 101) + ); + LayoutRender2D.roundedStroke(knobX, knobY, knob, knob, 4.5f * scale, 2f * scale, LayoutRender2D.argb(255, 155, 155, 165)); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/OnlineRelationPlayerPickerComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/OnlineRelationPlayerPickerComponent.java new file mode 100644 index 0000000..bd7c693 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/OnlineRelationPlayerPickerComponent.java @@ -0,0 +1,833 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.relations; + +import com.mojang.authlib.GameProfile; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiRichTextRenderer; +import dev.loki.lovisual.features.module.modules.misc.DefineTarget; +import dev.loki.lovisual.features.relations.CategoryService; +import dev.loki.lovisual.features.relations.CategoryType; +import dev.loki.lovisual.features.relations.PlayerRelations; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.core.ViewportContext; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.helpers.PlayerHeadRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.player.PlayerSkinResolver; +import dev.loki.lovisual.util.text.ChatNameUtil; +import dev.loki.lovisual.util.text.LegacyTextUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.multiplayer.PlayerInfo; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.GameType; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Online player picker for Relations/DefineTarget. + * Visually follows the custom tab-list shape, but writes directly to PlayerRelations. + */ +final class OnlineRelationPlayerPickerComponent { + private static final int MAX_ROWS_PER_COLUMN = 13; + private static final long STRUCTURE_REFRESH_MS = 350L; + private static final long DYNAMIC_REFRESH_MS = 250L; + private static final String I18N = "clickgui.settings.relations.online_picker."; + + private final Minecraft mc = Minecraft.getInstance(); + private final Consumer statusSink; + private final List rows = new ArrayList<>(); + private final List rowHits = new ArrayList<>(); + private final Map skinCache = new HashMap<>(); + + private boolean openTarget; + private boolean searchOpen; + private boolean searchFocused; + private String search = ""; + private DefineTarget.RelationTargetMode mode = DefineTarget.RelationTargetMode.FRIEND; + + private Rect bounds = Rect.ZERO; + private Rect searchButton = Rect.ZERO; + private Rect searchField = Rect.ZERO; + private Rect closeButton = Rect.ZERO; + private Rect listRect = Rect.ZERO; + + private float openAnim; + private float searchAnim; + private float scroll; + private float smoothedScroll; + private boolean draggingScrollbar; + private boolean scrollbarVisible; + private float scrollbarX; + private float scrollbarY; + private float scrollbarW; + private float scrollbarH; + private float scrollbarThumbY; + private float scrollbarThumbH; + private float scrollbarMaxScroll; + private float scrollbarDragOffset; + private long lastStructureCheckMs; + private long lastDynamicRefreshMs; + private long lastStructureSignature; + private int lastOnlineCount = -1; + private int rowsVersion; + private int filteredRowsVersion = -1; + private String filteredSearchKey = ""; + private List filteredRowsCache = List.of(); + + OnlineRelationPlayerPickerComponent(Consumer statusSink) { + this.statusSink = statusSink; + } + + boolean isVisible() { + return openTarget || openAnim > 0.002f; + } + + boolean blocksMovementInput() { + return openTarget && searchFocused; + } + + boolean isOpen() { + return openTarget; + } + + boolean isSearchFocused() { + return openTarget && searchFocused; + } + + String searchText() { + return search == null ? "" : search; + } + + void focusSearch() { + if (!openTarget) return; + searchFocused = true; + searchOpen = false; + } + + void clearSearch() { + if (search == null || search.isEmpty()) return; + search = ""; + resetScroll(); + invalidateFilter(); + } + + void open(DefineTarget.RelationTargetMode initialMode) { + openTarget = true; + searchOpen = false; + if (initialMode != null) mode = initialMode; + resetScroll(); + refreshNow(); + } + + void close() { + openTarget = false; + searchFocused = false; + draggingScrollbar = false; + if (!search.isEmpty()) { + search = ""; + invalidateFilter(); + } + } + + void toggle(DefineTarget.RelationTargetMode initialMode) { + if (openTarget) close(); + else open(initialMode); + } + + void resetScroll() { + scroll = 0f; + smoothedScroll = 0f; + draggingScrollbar = false; + } + + void render(float x, float y, float w, float h, float mx, float my, float scale, SettingsGuiPalette palette) { + float dt = AnimationUtility.deltaTime(); + openAnim = AnimationUtility.approach(openAnim, openTarget ? 1.0f : 0.0f, dt, openTarget ? 13.0f : 18.0f); + openAnim = AnimationUtility.snap(openAnim, openTarget ? 1.0f : 0.0f, 0.003f); + searchAnim = AnimationUtility.approach(searchAnim, searchFocused ? 1.0f : 0.0f, dt, 12.0f); + searchAnim = AnimationUtility.snap(searchAnim, searchFocused ? 1.0f : 0.0f, 0.004f); + if (!isVisible()) return; + bounds = new Rect(x, y, w, h); + rowHits.clear(); + refreshIfNeeded(); + + float enter = AnimationUtility.easeOutBack(openAnim, 0.42f); + float exit = AnimationUtility.easeInOutCubic(openAnim); + float visual = openTarget ? enter : exit; + float opacity = openTarget ? AnimationUtility.clamp01(openAnim * 1.16f) : AnimationUtility.smoothstep(openAnim); + float radius = 7.5f * scale; + float lift = openTarget ? (1.0f - visual) * 4.0f * scale : 0.0f; + float panelY = y + lift; + float panelH = openTarget ? h : Math.max(1.0f, h * (0.74f + 0.26f * visual)); + float panelBottom = panelY + panelH; + ClickGuiRenderer.drawBlur(x, panelY, w, panelH, radius, palette.panelBlurTint(), (170f / 255f) * opacity); + LayoutRender2D.roundedSoftShadow(x, panelY + 2f * scale, w, panelH, radius, 15f * scale, 0.18f * opacity, fade(SettingsGuiPalette.withAlpha(0xFF000000, 140), opacity)); + LayoutRender2D.roundedQuad( + x, + panelY, + w, + panelH, + radius, + fade(SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.panelBgLeft(), palette.menuCategorySelectedLeft(), 0.18f), 216), opacity), + fade(SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.panelBgRight(), palette.menuCategorySelectedRight(), 0.16f), 208), opacity), + fade(SettingsGuiPalette.withAlpha(SettingsGuiPalette.darken(palette.panelBgRight(), 0.12f), 214), opacity), + fade(SettingsGuiPalette.withAlpha(SettingsGuiPalette.darken(palette.panelBgLeft(), 0.10f), 220), opacity) + ); + Renderer2D.COLOR.radialGlowMasked( + x, + panelY, + w, + panelH, + radius, + 0.0f, + 92f * scale, + x + 42f * scale, + panelY + 30f * scale, + LayoutRender2D.alpha(modeColor(), 0.20f * opacity) + ); + LayoutRender2D.roundedStrokeQuad( + x, + panelY, + w, + panelH, + radius, + 0.65f * scale, + LayoutRender2D.alpha(modeColor(), 0.42f * opacity), + LayoutRender2D.alpha(palette.menuWindowStroke(), 0.78f * opacity), + LayoutRender2D.alpha(palette.menuWindowStroke(), 0.50f * opacity), + LayoutRender2D.alpha(modeColor(), 0.26f * opacity) + ); + + float pad = 8f * scale; + float headerH = 28f * scale; + renderHeader(x + pad, panelY + pad, w - pad * 2f, headerH - pad, mx, my, scale, palette, opacity); + + float listX = x + pad; + float listY = panelY + headerH + 4f * scale; + float listW = w - pad * 2f; + float listH = Math.max(1f, panelBottom - pad - listY); + listRect = new Rect(listX, listY, listW, listH); + renderRows(listX, listY, listW, listH, mx, my, scale, palette, opacity); + } + + private void renderHeader(float x, float y, float w, float h, float mx, float my, float scale, SettingsGuiPalette palette, float opacity) { + float titleSize = 8.2f * scale; + String title = tr("title", "Online players"); + String subtitle = search == null || search.isBlank() + ? tr("subtitle", "Pick from current tab list") + : tr("subtitle_search", "%s / %s visible", filteredRows().size(), rows.size()); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), title, x, y + 1.5f * scale, titleSize, fade(palette.panelText(), opacity), false); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), subtitle, x, y + 13.0f * scale, 6.15f * scale, fade(palette.panelMuted(), opacity), false); + + float btn = 17f * scale; + closeButton = new Rect(x + w - btn, y, btn, btn); + searchButton = Rect.ZERO; + searchField = Rect.ZERO; + drawSmallIconButton(closeButton, "x", false, closeButton.contains(mx, my), scale, palette, opacity); + } + + private void renderRows(float x, float y, float w, float h, float mx, float my, float scale, SettingsGuiPalette palette, float opacity) { + List filtered = filteredRows(); + if (filtered.isEmpty()) { + String text = search.isBlank() ? tr("empty", "No online players.") : tr("empty_search", "No matching online players."); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), text, x + 5f * scale, y + 5f * scale, 7.2f * scale, fade(palette.panelMuted(), opacity), false); + updateScrollbarMetrics(x, y, w, h, 0f, scale); + return; + } + + float reservedScrollbarW = 9f * scale; + int rowsPerColumn = filtered.size(); + float gap = 5f * scale; + float rowH = 31f * scale; + float stepY = rowH + gap; + float contentH = rowsPerColumn * stepY - gap; + float maxScroll = Math.max(0f, contentH - h); + updateScrollbarMetrics(x, y, w, h, maxScroll, scale); + float rowAreaW = scrollbarVisible ? Math.max(1f, w - reservedScrollbarW) : w; + float colW = rowAreaW; + if (draggingScrollbar) scrollToMouse(ClickGuiRenderer.getMouseY()); + + scroll = ClickGuiMath.clamp(scroll, -maxScroll, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, draggingScrollbar ? 0.55f : 0.22f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, draggingScrollbar ? 0.01f : 0.05f); + + boolean clipped = ScissorFunction.pushRaw(x, y, w, h); + for (int i = 0; i < filtered.size(); i++) { + int row = i; + float rowX = x; + float rowY = y + row * stepY + smoothedScroll; + if (rowY + rowH < y || rowY > y + h) continue; + PlayerRow player = filtered.get(i); + renderRow(player, rowX, rowY, colW, rowH, mx, my, scale, palette, i, opacity); + } + if (clipped) ScissorFunction.pop(); + renderScrollbar(x, y, w, h, maxScroll, scale, palette, opacity); + } + + private void renderRow(PlayerRow player, float x, float y, float w, float h, float mx, float my, float scale, SettingsGuiPalette palette, int index, float opacity) { + float rowBase = openTarget ? AnimationUtility.easeOutCubic(openAnim) : 1.0f; + float rowAnim = AnimationUtility.clamp((rowBase - index * 0.018f) / 0.72f, 0f, 1f); + float rowEase = openTarget ? AnimationUtility.easeOutCubic(rowAnim) : 1.0f; + y += (1f - rowEase) * 7f * scale; + boolean hover = openTarget && ClickGuiMath.insideRect(mx, my, x, y, w, h); + boolean selected = DefineTarget.isPlayerInRelationMode(mode, player.name()); + int relationColor = player.relationColor() != 0 ? player.relationColor() : modeColor(); + int top = hover ? SettingsGuiPalette.mix(palette.moduleCardTop(), modeColor(), 0.10f) : palette.moduleCardTop(); + int topStrong = hover || selected ? SettingsGuiPalette.mix(palette.moduleCardTopStrong(), relationColor, selected ? 0.18f : 0.10f) : palette.moduleCardTopStrong(); + int bottom = hover ? SettingsGuiPalette.mix(palette.moduleCardBottom(), modeColor(), 0.12f) : palette.moduleCardBottom(); + int bottomStrong = selected ? SettingsGuiPalette.mix(palette.moduleCardBottomStrong(), relationColor, 0.20f) : palette.moduleCardBottomStrong(); + + LayoutRender2D.roundedSoftShadow(x, y + 1f * scale, w, h, 5.5f * scale, 8f * scale, 0.08f, fade(SettingsGuiPalette.withAlpha(0xFF000000, hover ? 96 : 58), opacity)); + LayoutRender2D.roundedQuad(x, y, w, h, 5.5f * scale, fade(top, opacity), fade(topStrong, opacity), fade(bottomStrong, opacity), fade(bottom, opacity)); + Renderer2D.COLOR.radialGlowMasked(x, y, w, h, 5.5f * scale, 0f, 42f * scale, x + 15f * scale, y + h * 0.50f, LayoutRender2D.alpha(relationColor, (selected ? 0.28f : 0.14f) * opacity)); + LayoutRender2D.roundedStrokeQuad(x, y, w, h, 5.5f * scale, 0.55f * scale, + LayoutRender2D.alpha(relationColor, (selected ? 0.70f : 0.34f) * opacity), + LayoutRender2D.alpha(palette.menuWindowStroke(), 0.72f * opacity), + LayoutRender2D.alpha(palette.menuWindowStroke(), 0.44f * opacity), + LayoutRender2D.alpha(relationColor, (selected ? 0.46f : 0.20f) * opacity)); + + float head = 18.5f * scale; + float headX = x + 6f * scale; + float headY = y + (h - head) * 0.5f; + renderHead(resolveSkin(player), headX, headY, head, scale, palette, opacity); + + float dot = 5.3f * scale; + float dotX = headX + head - dot * 0.72f; + float dotY = headY + head - dot * 0.72f; + Renderer2D.COLOR.radialGlowMasked(dotX - 4f * scale, dotY - 4f * scale, dot + 8f * scale, dot + 8f * scale, + (dot + 8f * scale) * 0.5f, 0f, 11f * scale, dotX + dot * 0.5f, dotY + dot * 0.5f, LayoutRender2D.alpha(relationColor, 0.48f * opacity)); + Renderer2D.COLOR.roundedRect(dotX, dotY, dot, dot, dot * 0.5f, 0.7f, fade(relationColor | 0xFF000000, opacity)); + Renderer2D.COLOR.roundedRectStroke(dotX, dotY, dot, dot, dot * 0.5f, 0.7f, 0.55f * scale, LayoutRender2D.alpha(0xFFFFFFFF, 0.42f * opacity)); + + float textX = headX + head + 7f * scale; + float rightPad = selected ? 56f * scale : 44f * scale; + float pingW = Math.max(38f * scale, ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), pingText(player.latency()), 6.2f * scale) + 26f * scale); + float nameW = Math.max(20f * scale, w - (textX - x) - rightPad - pingW); + int nameColor = player.spectator() ? palette.panelMuted() : palette.panelText(); + ClickGuiRichTextRenderer.draw(player.displayName(), textX, y + 6.0f * scale, nameW, 8.0f * scale, nameColor, opacity, false); + + String relation = relationLabel(player.relation()); + String relationText = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterRegular(), relation, 5.5f * scale, nameW); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), relationText, textX, y + 18.6f * scale, 5.5f * scale, + fade(SettingsGuiPalette.mix(palette.panelMuted(), relationColor | 0xFF000000, 0.45f), opacity), false); + + float pingChipH = 13f * scale; + float pingChipX = x + w - 6f * scale - pingW; + float pingChipY = y + (h - pingChipH) * 0.5f; + drawPingChip(player.latency(), pingChipX, pingChipY, pingW, pingChipH, scale, palette, opacity); + + Rect hit = new Rect(x, y, w, h); + rowHits.add(new RowHit(player.name(), hit)); + if (hover) SystemCursor.set(SystemCursor.CursorType.HAND); + } + + boolean click(float mx, float my, int button) { + if (!openTarget || button != 0) return false; + if (closeButton.contains(mx, my)) { + close(); + return true; + } + if (searchButton.contains(mx, my)) { + focusSearch(); + return true; + } + if (searchField.contains(mx, my)) { + focusSearch(); + return true; + } + for (RowHit hit : rowHits) { + if (!hit.rect().contains(mx, my)) continue; + addPlayer(hit.name()); + return true; + } + if (bounds.contains(mx, my)) { + searchFocused = false; + return true; + } + return false; + } + + boolean mousePressedScrollbar(float mx, float my, int button) { + if (!openTarget || button != 0 || !isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollbarDragOffset = ClickGuiMath.insideRect(mx, my, scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH) + ? my - scrollbarThumbY + : scrollbarThumbH * 0.5f; + scrollToMouse(my); + return true; + } + + void mouseReleased(int button) { + if (button == 0) draggingScrollbar = false; + } + + void scroll(float mx, float my, double amount) { + if (!openTarget || !listRect.contains(mx, my)) return; + scroll += (float) (amount * 24f); + } + + boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (!openTarget) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + if (ctrl && keyCode == GLFW.GLFW_KEY_F) { + focusSearch(); + return true; + } + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + if (searchFocused) { + searchFocused = false; + return true; + } + close(); + return true; + } + if (!searchFocused) return false; + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + searchFocused = false; + return true; + } + if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { + if (!search.isEmpty()) { + search = search.substring(0, search.length() - 1); + resetScroll(); + invalidateFilter(); + } + return true; + } + return true; + } + + boolean charTyped(char chr, int modifiers) { + if (!openTarget || !searchFocused) return false; + if (chr >= 32 && chr != 127) { + search += chr; + if (search.length() > 32) search = search.substring(0, 32); + resetScroll(); + invalidateFilter(); + } + return true; + } + + private void addPlayer(String rawName) { + String name = ChatNameUtil.normalizeNickCandidate(rawName); + if (!ChatNameUtil.isNickLike(name)) { + setStatus(tr("invalid", "Invalid nick")); + return; + } + String modeName = modeDisplayName(mode); + boolean wasInMode = DefineTarget.isPlayerInRelationMode(mode, name); + boolean added = DefineTarget.togglePlayerRelation(mode, name); + refreshNow(); + if (wasInMode && !added) { + setStatus(tr("removed", "Removed %s from %s", name, modeName)); + } else { + setStatus(tr("added", "Added %s as %s", name, modeName)); + } + } + + private void refreshIfNeeded() { + if (mc == null || mc.getConnection() == null) return; + long now = System.currentTimeMillis(); + int onlineCount = onlineCount(); + boolean checkStructure = rows.isEmpty() || onlineCount != lastOnlineCount || now - lastStructureCheckMs >= STRUCTURE_REFRESH_MS; + if (checkStructure) { + long signature = structureSignature(); + lastStructureCheckMs = now; + if (rows.isEmpty() || onlineCount != lastOnlineCount || signature != lastStructureSignature) { + collectRows(); + lastOnlineCount = onlineCount; + lastStructureSignature = signature; + lastDynamicRefreshMs = now; + return; + } + } + + if (now - lastDynamicRefreshMs >= DYNAMIC_REFRESH_MS) { + refreshDynamicRows(); + lastDynamicRefreshMs = now; + } + } + + private void refreshNow() { + collectRows(); + lastOnlineCount = onlineCount(); + lastStructureSignature = structureSignature(); + lastStructureCheckMs = System.currentTimeMillis(); + lastDynamicRefreshMs = lastStructureCheckMs; + } + + private int onlineCount() { + return mc != null && mc.getConnection() != null ? mc.getConnection().getListedOnlinePlayers().size() : 0; + } + + private void collectRows() { + rows.clear(); + if (mc == null || mc.getConnection() == null) { + rowsVersion++; + invalidateFilter(); + return; + } + Collection online = mc.getConnection().getListedOnlinePlayers(); + List sorted = new ArrayList<>(online); + sorted.sort(Comparator + .comparingInt((PlayerInfo info) -> -info.getTabListOrder()) + .thenComparing(info -> info.getGameMode() == GameType.SPECTATOR) + .thenComparing(info -> teamName(info).toLowerCase(Locale.ROOT)) + .thenComparing(info -> profileName(info).toLowerCase(Locale.ROOT), String.CASE_INSENSITIVE_ORDER)); + + Set liveIds = new HashSet<>(); + for (PlayerInfo info : sorted) { + if (info == null || info.getProfile() == null) continue; + GameProfile profile = info.getProfile(); + UUID id = profile.id(); + String name = profile.name(); + if (id == null || name == null || name.isBlank()) continue; + Component display = displayName(info, name); + CategoryType relation = CategoryService.get(name); + int relationColor = relation == CategoryType.DEFAULT ? PlayerRelations.get().colorDefault() : CategoryService.getColor(name); + rows.add(new PlayerRow(id, profile, name, display, searchText(name, display), relation, relationColor, info.getLatency(), info.getGameMode() == GameType.SPECTATOR)); + liveIds.add(id); + } + skinCache.keySet().retainAll(liveIds); + rowsVersion++; + invalidateFilter(); + } + + private void refreshDynamicRows() { + if (mc == null || mc.getConnection() == null || rows.isEmpty()) return; + boolean changed = false; + for (int i = 0; i < rows.size(); i++) { + PlayerRow row = rows.get(i); + PlayerInfo info = mc.getConnection().getPlayerInfo(row.id()); + if (info == null || info.getProfile() == null) { + collectRows(); + return; + } + CategoryType relation = CategoryService.get(row.name()); + int relationColor = relation == CategoryType.DEFAULT ? PlayerRelations.get().colorDefault() : CategoryService.getColor(row.name()); + int latency = info.getLatency(); + boolean spectator = info.getGameMode() == GameType.SPECTATOR; + if (latency == row.latency() && spectator == row.spectator() && relation == row.relation() && relationColor == row.relationColor()) { + continue; + } + rows.set(i, new PlayerRow(row.id(), row.profile(), row.name(), row.displayName(), row.searchText(), relation, relationColor, latency, spectator)); + changed = true; + } + if (changed) { + rowsVersion++; + invalidateFilter(); + } + } + + private long structureSignature() { + if (mc == null || mc.getConnection() == null) return 0L; + long h = 0x6A09E667F3BCC909L; + for (PlayerInfo info : mc.getConnection().getListedOnlinePlayers()) { + if (info == null || info.getProfile() == null) continue; + GameProfile profile = info.getProfile(); + h = mix(h, profile.id()); + h = mix(h, profile.name()); + h = mix(h, info.getTabListOrder()); + h = mix(h, info.getGameMode() == GameType.SPECTATOR ? 1 : 0); + h = mix(h, teamName(info)); + Component rawDisplay = info.getTabListDisplayName(); + h = mix(h, rawDisplay != null ? rawDisplay.getString() : ""); + String name = profile.name() == null ? "" : profile.name(); + CategoryType relation = CategoryService.get(name); + h = mix(h, relation.ordinal()); + h = mix(h, relation == CategoryType.DEFAULT ? PlayerRelations.get().colorDefault() : CategoryService.getColor(name)); + } + return h; + } + + private Component displayName(PlayerInfo info, String fallback) { + Component display = null; + if (mc != null && mc.gui != null && mc.gui.hud != null && mc.gui.hud.getTabList() != null) { + display = mc.gui.hud.getTabList().getNameForDisplay(info); + } + if (display == null) display = info.getTabListDisplayName(); + if (display == null || display.getString().isBlank()) display = Component.literal(fallback); + return LegacyTextUtil.convertLegacyCodes(display); + } + + private List filteredRows() { + String q = normalizedSearch(); + if (filteredRowsVersion == rowsVersion && filteredSearchKey.equals(q)) return filteredRowsCache; + if (q.isBlank()) { + filteredRowsCache = rows; + } else { + List out = new ArrayList<>(); + for (PlayerRow row : rows) { + if (row.searchText().contains(q)) out.add(row); + } + filteredRowsCache = out; + } + filteredRowsVersion = rowsVersion; + filteredSearchKey = q; + return filteredRowsCache; + } + + private String normalizedSearch() { + return search == null ? "" : LegacyTextUtil.stripLegacy(search).trim().toLowerCase(Locale.ROOT); + } + + private void invalidateFilter() { + filteredRowsVersion = -1; + } + + private Identifier resolveSkin(PlayerRow player) { + if (player == null || player.id() == null) return null; + Identifier cached = skinCache.get(player.id()); + if (cached != null) return cached; + Identifier skin = PlayerSkinResolver.resolveProfileSkin(player.profile()); + if (skin != null) skinCache.put(player.id(), skin); + return skin; + } + + private static String searchText(String name, Component displayName) { + String raw = (name == null ? "" : name) + " " + (displayName == null ? "" : displayName.getString()); + return LegacyTextUtil.stripLegacy(raw).trim().toLowerCase(Locale.ROOT); + } + + private static long mix(long h, int value) { + return (h ^ value) * 0x100000001B3L; + } + + private static long mix(long h, UUID value) { + if (value == null) return mix(h, 0); + h = (h ^ value.getMostSignificantBits()) * 0x100000001B3L; + return (h ^ value.getLeastSignificantBits()) * 0x100000001B3L; + } + + private static long mix(long h, String value) { + return (h ^ (value != null ? value.hashCode() : 0)) * 0x100000001B3L; + } + + private void drawPingChip(int latency, float x, float y, float w, float h, float scale, SettingsGuiPalette palette, float opacity) { + String text = pingText(latency); + int base = pingBaseColor(latency); + int chipA = LayoutRender2D.alpha(palette.panelPillBase(), 0.84f * opacity); + int chipB = LayoutRender2D.alpha(SettingsGuiPalette.mix(palette.panelPillBase(), palette.moduleCardBottomStrong(), 0.38f), 0.52f * opacity); + LayoutRender2D.roundedQuad(x, y, w, h, 5.4f * scale, chipA, chipA, chipB, chipB); + LayoutRender2D.roundedStrokeQuad(x, y, w, h, 5.4f * scale, 0.55f * scale, + LayoutRender2D.alpha(palette.panelStroke(), 0.40f * opacity), + LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.26f * opacity), + LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.18f * opacity), + LayoutRender2D.alpha(palette.panelStroke(), 0.30f * opacity)); + + float cx = x + 7.3f * scale; + float cy = y + h * 0.5f; + float r = 3.1f * scale; + Renderer2D.COLOR.circleSoftShadow(cx, cy, r + 1.5f * scale, 5.0f * scale, 0.34f, LayoutRender2D.alpha(base, 0.48f * opacity)); + Renderer2D.COLOR.circle(cx, cy, r + 0.95f * scale, 0.8f * scale, LayoutRender2D.alpha(base, 0.30f * opacity)); + Renderer2D.COLOR.circle(cx, cy, r, 0.75f * scale, LayoutRender2D.alpha(base, opacity)); + Renderer2D.COLOR.circleStroke(cx, cy, r + 0.72f * scale, 0.55f * scale, 0.8f * scale, LayoutRender2D.alpha(0xFFFFFFFF, 0.36f * opacity)); + Renderer2D.COLOR.arcStrokeGradient(cx, cy, r + 1.55f * scale, 0.9f * scale, -140f, 70f, 0.45f * scale, + LayoutRender2D.alpha(base, 0.68f * opacity), + LayoutRender2D.alpha(0xFFFFFFFF, 0.16f * opacity), + 35f, 0f); + + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), text, x + 14.5f * scale, y + 3.3f * scale, 6.2f * scale, fade(palette.panelText(), opacity), false); + } + + private void drawSmallIconButton(Rect rect, String icon, boolean active, boolean hover, float scale, SettingsGuiPalette palette, float opacity) { + int color = active ? modeColor() : palette.panelMuted(); + int bgA = active || hover ? SettingsGuiPalette.mix(palette.panelPillBase(), color, active ? 0.30f : 0.14f) : LayoutRender2D.alpha(palette.panelPillBase(), 0.82f); + int bgB = active || hover ? SettingsGuiPalette.mix(palette.moduleCardTopStrong(), color, active ? 0.18f : 0.08f) : LayoutRender2D.alpha(palette.panelPillBase(), 0.70f); + LayoutRender2D.roundedQuad(rect.x(), rect.y(), rect.w(), rect.h(), 4.3f * scale, fade(bgA, opacity), fade(bgB, opacity), fade(bgB, opacity), fade(bgA, opacity)); + LayoutRender2D.roundedStroke(rect.x(), rect.y(), rect.w(), rect.h(), 4.3f * scale, 0.45f * scale, LayoutRender2D.alpha(color, (active || hover ? 0.56f : 0.24f) * opacity)); + Renderer2D.COLOR.svg(icon, rect.x() + 4.1f * scale, rect.y() + 4.1f * scale, rect.w() - 8.2f * scale, rect.h() - 8.2f * scale, + SvgRenderOptions.overrideColor(fade(active ? palette.panelText() : SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), hover ? 0.25f : 0.0f), opacity))); + if (hover) SystemCursor.set(SystemCursor.CursorType.HAND); + } + + private void drawSearchField(Rect rect, float mx, float my, float scale, SettingsGuiPalette palette, float opacity) { + boolean hover = rect.contains(mx, my); + int stroke = searchFocused ? modeColor() : palette.panelStroke(); + ClickGuiRenderer.drawBlur(rect.x(), rect.y(), rect.w(), rect.h(), 4.8f * scale, palette.panelBlurTint(), (100f / 255f) * opacity); + LayoutRender2D.roundedQuad(rect.x(), rect.y(), rect.w(), rect.h(), 4.8f * scale, + LayoutRender2D.alpha(palette.panelPillBase(), (hover ? 1.0f : 0.84f) * opacity), + LayoutRender2D.alpha(palette.panelPillBase(), (hover ? 0.92f : 0.76f) * opacity), + LayoutRender2D.alpha(palette.panelPillBase(), (hover ? 0.82f : 0.68f) * opacity), + LayoutRender2D.alpha(palette.panelPillBase(), (hover ? 0.96f : 0.76f) * opacity)); + LayoutRender2D.roundedStroke(rect.x(), rect.y(), rect.w(), rect.h(), 4.8f * scale, 0.55f * scale, LayoutRender2D.alpha(stroke, (searchFocused ? 0.74f : 0.42f) * opacity)); + boolean clipped = ScissorFunction.pushRaw(rect.x(), rect.y(), rect.w(), rect.h()); + String text = search == null || search.isEmpty() ? tr("search_placeholder", "Search online player") : search; + int color = search == null || search.isEmpty() ? palette.panelMuted() : palette.panelText(); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), text, rect.x() + 7f * scale, rect.y() + 5.0f * scale, 6.8f * scale, fade(color, opacity), false); + if (searchFocused && ((System.currentTimeMillis() / 520L) & 1L) == 0L && rect.h() > 10f * scale) { + float tx = rect.x() + 7f * scale + ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), search, 6.8f * scale) + 1.0f * scale; + LayoutRender2D.rect(tx, rect.y() + 4.6f * scale, 0.8f * scale, Math.max(0.5f * scale, rect.h() - 9.2f * scale), LayoutRender2D.alpha(modeColor(), 0.86f * opacity)); + } + if (clipped) ScissorFunction.pop(); + if (hover || searchFocused) SystemCursor.set(SystemCursor.CursorType.TEXT); + } + + private void renderHead(Identifier skin, float x, float y, float size, float scale, SettingsGuiPalette palette, float opacity) { + float headOpacity = AnimationUtility.clamp01(opacity * ClickGuiRenderer.getRenderAlphaMultiplier()); + if (headOpacity <= 0.01f) return; + GuiGraphicsExtractor ctx = ViewportContext.getCurrentContext(); + if (skin != null && ctx != null) { + int alpha = Math.round(255f * headOpacity); + PlayerHeadRenderer.drawRounded(ctx, x, y, size, 4.2f * scale, skin, new RenderColor(255, 255, 255, alpha), true, + new RenderColor(255, 255, 255, Math.round(48f * headOpacity)), 0.95f * scale, false); + return; + } + Renderer2D.COLOR.roundedRect(x, y, size, size, 4.2f * scale, 1.0f, LayoutRender2D.alpha(palette.panelMuted(), 0.22f * headOpacity)); + Renderer2D.COLOR.roundedRectStroke(x, y, size, size, 4.2f * scale, 1.0f, 0.55f * scale, LayoutRender2D.alpha(palette.panelMuted(), 0.62f * headOpacity)); + } + + private void renderScrollbar(float x, float y, float w, float h, float maxScroll, float scale, SettingsGuiPalette palette, float opacity) { + updateScrollbarMetrics(x, y, w, h, maxScroll, scale); + if (!scrollbarVisible) return; + if (isScrollbarHovered(ClickGuiRenderer.getMouseX(), ClickGuiRenderer.getMouseY()) || draggingScrollbar) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + LayoutRender2D.roundedQuad(scrollbarX, scrollbarY, scrollbarW, scrollbarH, 2f * scale, + fade(palette.panelScrollTrackA(), opacity), fade(palette.panelScrollTrackB(), opacity), fade(palette.panelScrollTrackB(), opacity), fade(palette.panelScrollTrackA(), opacity)); + LayoutRender2D.roundedQuad(scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH, 2f * scale, + fade(palette.panelScrollHandleA(), opacity), fade(palette.panelScrollHandleB(), opacity), fade(palette.panelScrollHandleB(), opacity), fade(palette.panelScrollHandleA(), opacity)); + } + + private void updateScrollbarMetrics(float x, float y, float w, float h, float maxScroll, float scale) { + scrollbarVisible = maxScroll > 0.5f; + scrollbarMaxScroll = Math.max(0f, maxScroll); + if (!scrollbarVisible) { + draggingScrollbar = false; + scrollbarX = scrollbarY = scrollbarW = scrollbarH = scrollbarThumbY = scrollbarThumbH = 0f; + return; + } + scrollbarW = 3f * scale; + scrollbarX = x + w - scrollbarW - 2f * scale; + scrollbarY = y + 3f * scale; + scrollbarH = Math.max(1f, h - 6f * scale); + scrollbarThumbH = Math.max(18f * scale, scrollbarH * (scrollbarH / (scrollbarMaxScroll + scrollbarH))); + float scrollRatio = scrollbarMaxScroll <= 0f ? 0f : (-smoothedScroll / scrollbarMaxScroll); + scrollbarThumbY = scrollbarY + (scrollbarH - scrollbarThumbH) * AnimationUtility.clamp(scrollRatio, 0f, 1f); + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 4f; + return ClickGuiMath.insideRect(mx, my, scrollbarX - pad, scrollbarY - pad, scrollbarW + pad * 2f, scrollbarH + pad * 2f); + } + + private void scrollToMouse(float my) { + if (!scrollbarVisible || scrollbarMaxScroll <= 0f) return; + float span = scrollbarH - scrollbarThumbH; + if (span <= 0.5f) return; + float thumbTop = AnimationUtility.clamp(my - scrollbarDragOffset, scrollbarY, scrollbarY + span); + float ratio = (thumbTop - scrollbarY) / span; + scroll = -scrollbarMaxScroll * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private static int fade(int color, float opacity) { + return LayoutRender2D.alpha(color, AnimationUtility.clamp01(opacity)); + } + + private int modeColor() { + return mode.color(PlayerRelations.get()) | 0xFF000000; + } + + + private static int pingBaseColor(int latency) { + if (latency < 0) return 0xFF8993A3; + if (latency < 90) return 0xFF52F2A1; + if (latency < 180) return 0xFFFFD166; + return 0xFFFF5A6E; + } + + private static int pingColor(int latency) { + if (latency < 0) return 0xFF9AA5B5; + if (latency < 75) return 0xFF66E086; + if (latency < 150) return 0xFFE8D36E; + if (latency < 300) return 0xFFFFA756; + return 0xFFFF5E6A; + } + + private static String pingText(int latency) { + return latency < 0 ? "?" : String.valueOf(latency); + } + + private static String modeDisplayName(DefineTarget.RelationTargetMode mode) { + if (mode == null) return ""; + return tr("mode_name." + mode.id(), mode.fallbackLabel()); + } + + private static String relationLabel(CategoryType type) { + if (type == null) return tr("relation.default", "Default"); + return switch (type) { + case FRIEND -> tr("relation.friend", "Friend"); + case ENEMY -> tr("relation.enemy", "Enemy"); + case STAFF -> tr("relation.staff", "Staff"); + case BEDWARS_SELF -> tr("relation.bedwars_self", "Bedwars Self"); + case BEDWARS_ENEMY -> tr("relation.bedwars_enemy", "Bedwars Enemy"); + default -> tr("relation.default", "Default"); + }; + } + + private static String teamName(PlayerInfo info) { + return info != null && info.getTeam() != null && info.getTeam().getName() != null ? info.getTeam().getName() : ""; + } + + private static String profileName(PlayerInfo info) { + GameProfile profile = info != null ? info.getProfile() : null; + return profile != null && profile.name() != null ? profile.name() : ""; + } + + private void setStatus(String text) { + if (statusSink != null) statusSink.accept(text); + } + + private static String tr(String key, String fallback, Object... args) { + return ClickGuiI18n.tr(I18N + key, fallback, args); + } + + private record PlayerRow(UUID id, + GameProfile profile, + String name, + Component displayName, + String searchText, + CategoryType relation, + int relationColor, + int latency, + boolean spectator) { + } + + private record RowHit(String name, Rect rect) { + } + + private record Rect(float x, float y, float w, float h) { + static final Rect ZERO = new Rect(0f, 0f, 0f, 0f); + + boolean contains(float mx, float my) { + return ClickGuiMath.insideRect(mx, my, x, y, w, h); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationPlayerCardComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationPlayerCardComponent.java new file mode 100644 index 0000000..b00bc57 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationPlayerCardComponent.java @@ -0,0 +1,119 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.relations; + +import dev.loki.lovisual.features.account.SkinManager; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.core.ViewportContext; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.helpers.PlayerHeadRenderer; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; + +public final class RelationPlayerCardComponent { + public CardHit render(String name, + float x, + float y, + float w, + float h, + float mx, + float my, + float scale, + SettingsGuiPalette palette) { + boolean hover = ClickGuiMath.insideRect(mx, my, x, y, w, h); + float opacity = ClickGuiRenderer.getRenderAlphaMultiplier(); + if (opacity <= 0.01f) { + return new CardHit(0f, 0f, 0f, 0f, null); + } + int top = hover ? SettingsGuiPalette.mix(palette.moduleCardTop(), palette.menuCategoryHoverLeft(), 0.14f) : palette.moduleCardTop(); + int topStrong = hover ? SettingsGuiPalette.mix(palette.moduleCardTopStrong(), palette.menuCategoryHoverRight(), 0.12f) : palette.moduleCardTopStrong(); + int bottom = hover ? SettingsGuiPalette.mix(palette.moduleCardBottom(), palette.menuCategoryHoverLeft(), 0.18f) : palette.moduleCardBottom(); + int bottomStrong = hover ? SettingsGuiPalette.mix(palette.moduleCardBottomStrong(), palette.menuCategoryHoverRight(), 0.14f) : palette.moduleCardBottomStrong(); + + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad(x, y, w, h, 5f * scale, top, topStrong, bottom, bottomStrong); + + float head = 19f * scale; + float headX = x + 7f * scale; + float headY = y + (h - head) * 0.5f; + renderHead(name, headX, headY, head, scale, palette, opacity); + + float textX = headX + head + 7f * scale; + float titleSize = 8.8f * scale; + float titleW = Math.max(1f, w - (textX - x) - 32f * scale); + String title = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterMedium(), name, titleSize, titleW); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterMedium(), + title, + textX, + y + (h - ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), titleSize)) * 0.5f, + titleSize, + palette.moduleTitleText(), + false + ); + + float btn = 16f * scale; + float deleteX = x + w - 7f * scale - btn; + float deleteY = y + (h - btn) * 0.5f; + drawAction(deleteX, deleteY, btn, "trash-2", mx, my, scale, palette); + + return new CardHit(x, y, w, h, new ActionHit(deleteX, deleteY, btn, btn)); + } + + private void renderHead(String name, float x, float y, float size, float scale, SettingsGuiPalette palette, float opacity) { + Identifier skin = SkinManager.getSkin(name); + GuiGraphicsExtractor ctx = ViewportContext.getCurrentContext(); + if (skin != null && ctx != null) { + int alpha = Math.round(255f * AnimationUtility.clamp01(opacity)); + PlayerHeadRenderer.drawRounded( + ctx, + x, + y, + size, + 4f * scale, + skin, + new RenderColor(255, 255, 255, alpha), + true, + new RenderColor(255, 255, 255, Math.round(42f * AnimationUtility.clamp01(opacity))), + 0.8f * scale, + false + ); + return; + } + + Renderer2D.COLOR.roundedRect(x, y, size, size, 4f * scale, 1.0f, LayoutRender2D.alpha(palette.panelMuted(), 0.22f * opacity)); + Renderer2D.COLOR.roundedRectStroke(x, y, size, size, 4f * scale, 1.0f, 0.55f * scale, LayoutRender2D.alpha(palette.panelMuted(), 0.62f * opacity)); + } + + private void drawAction(float x, float y, float size, String icon, float mx, float my, float scale, SettingsGuiPalette palette) { + boolean hover = ClickGuiMath.insideRect(mx, my, x, y, size, size); + int bgA = hover ? SettingsGuiPalette.mix(palette.moduleCardTop(), palette.menuCategoryHoverLeft(), 0.36f) : LayoutRender2D.alpha(palette.moduleCardTop(), 0.88f); + int bgB = hover ? SettingsGuiPalette.mix(palette.moduleCardTopStrong(), palette.menuCategoryHoverRight(), 0.28f) : LayoutRender2D.alpha(palette.moduleCardTopStrong(), 0.88f); + LayoutRender2D.roundedQuad(x, y, size, size, 4f * scale, bgA, bgB, bgB, bgA); + Renderer2D.COLOR.svg( + icon, + x + 4f * scale, + y + 4f * scale, + size - 8f * scale, + size - 8f * scale, + SvgRenderOptions.overrideColor(palette.menuCategoryText()) + ); + } + + public record CardHit(float x, float y, float w, float h, ActionHit delete) { + } + + public record ActionHit(float x, float y, float w, float h) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationsComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationsComponent.java new file mode 100644 index 0000000..135f982 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/relations/RelationsComponent.java @@ -0,0 +1,844 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.relations; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiSearch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.relations.RelationPlayerCardComponent.CardHit; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.module.modules.misc.DefineTarget; +import dev.loki.lovisual.features.relations.PlayerRelations; +import dev.loki.lovisual.features.relations.StaffHeuristicsConfig; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.text.ChatNameUtil; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public final class RelationsComponent { + private static final long STATUS_DURATION_MS = 2200L; + private static final String I18N = "clickgui.settings.relations."; + + private final RelationPlayerCardComponent cardComponent = new RelationPlayerCardComponent(); + private final OnlineRelationPlayerPickerComponent onlinePicker; + private final List cardHits = new ArrayList<>(); + private final List chipHits = new ArrayList<>(); + + private RelationTab tab = RelationTab.FRIENDS; + private ActiveField activeField = ActiveField.NONE; + private String playerInput = ""; + private String prefixInput = ""; + private String suffixInput = ""; + private String containsInput = ""; + private String statusMessage; + private long statusUntilMs; + + private Rect friendsPill = Rect.ZERO; + private Rect enemiesPill = Rect.ZERO; + private Rect staffPill = Rect.ZERO; + private Rect playerInputRect = Rect.ZERO; + private Rect addTypedButton = Rect.ZERO; + private Rect reservedPickerButton = Rect.ZERO; + private Rect enabledToggle = Rect.ZERO; + private Rect prefixInputRect = Rect.ZERO; + private Rect suffixInputRect = Rect.ZERO; + private Rect containsInputRect = Rect.ZERO; + private Rect prefixAddButton = Rect.ZERO; + private Rect suffixAddButton = Rect.ZERO; + private Rect containsAddButton = Rect.ZERO; + + private float scroll; + private float smoothedScroll; + private boolean scrollbarVisible; + private boolean draggingScrollbar; + private float scrollbarX; + private float scrollbarY; + private float scrollbarW; + private float scrollbarH; + private float scrollbarThumbY; + private float scrollbarThumbH; + private float scrollbarMaxScroll; + private float scrollbarDragOffset; + private float listX; + private float listY; + private float listW; + private float listH; + private float modeAnim; + private float friendsHoverAnim; + private float enemiesHoverAnim; + private float staffHoverAnim; + private float addHoverAnim; + private float pickerHoverAnim; + private static boolean movementInputBlocked; + + public RelationsComponent() { + this.onlinePicker = new OnlineRelationPlayerPickerComponent(this::setStatus); + } + + public void resetScroll() { + scroll = 0f; + smoothedScroll = 0f; + draggingScrollbar = false; + activeField = ActiveField.NONE; + onlinePicker.close(); + onlinePicker.resetScroll(); + movementInputBlocked = false; + } + + public static boolean isMovementInputBlocked() { + return movementInputBlocked; + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float scale) { + cardHits.clear(); + chipHits.clear(); + SettingsGuiPalette palette = SettingsGuiPalette.current(); + movementInputBlocked = activeField != ActiveField.NONE || onlinePicker.blocksMovementInput(); + + float areaX = menuX + 31f * scale; + float areaY = menuY + 33f * scale; + float areaW = menuW - 42f * scale; + float areaH = menuH - 39f * scale; + + renderToolbar(areaX, areaY, areaW, mx, my, scale, palette); + + float y = areaY + 24f * scale; + renderStatus(areaX, y, areaW, scale, palette); + y += 13f * scale; + + listX = areaX; + listY = y; + listW = areaW; + listH = Math.max(1f, areaY + areaH - y); + if (onlinePicker.isVisible()) { + onlinePicker.render(listX, listY, listW, listH, mx, my, scale, palette); + return; + } + + if (tab == RelationTab.STAFF) { + float heurH = 78f * scale; + renderHeuristicsPanel(areaX, y, areaW, heurH, mx, my, scale, palette); + y += heurH + 8f * scale; + } + + listX = areaX; + listY = y; + listW = areaW; + listH = Math.max(1f, areaY + areaH - y); + renderCards(listX, listY, listW, listH, filteredEntries(tab.entries()), mx, my, scale, palette); + } + + public boolean mousePressedScrollbar(float mx, float my, int button) { + if (onlinePicker.isVisible()) return onlinePicker.mousePressedScrollbar(mx, my, button); + if (button != 0 || !isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollbarDragOffset = ClickGuiMath.insideRect(mx, my, scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH) + ? my - scrollbarThumbY + : scrollbarThumbH * 0.5f; + scrollToMouse(my); + return true; + } + + public void mouseReleased(int button) { + onlinePicker.mouseReleased(button); + if (button == 0) draggingScrollbar = false; + } + + public void scroll(float mx, float my, double amount) { + if (onlinePicker.isVisible()) { + onlinePicker.scroll(mx, my, amount); + return; + } + if (!ClickGuiMath.insideRect(mx, my, listX, listY, listW, listH)) return; + scroll += (float) (amount * 22f); + } + + public boolean click(float mx, float my, int button) { + if (button != 0) return false; + + if (friendsPill.contains(mx, my)) return switchTab(RelationTab.FRIENDS); + if (enemiesPill.contains(mx, my)) return switchTab(RelationTab.ENEMIES); + if (staffPill.contains(mx, my)) return switchTab(RelationTab.STAFF); + + if (playerInputRect.contains(mx, my)) { + if (onlinePicker.isOpen()) { + activeField = ActiveField.NONE; + onlinePicker.focusSearch(); + } else { + activeField = ActiveField.PLAYER; + } + movementInputBlocked = activeField != ActiveField.NONE || onlinePicker.blocksMovementInput(); + clearStatus(); + return true; + } + if (addTypedButton.contains(mx, my)) { + if (onlinePicker.isOpen()) { + onlinePicker.clearSearch(); + onlinePicker.focusSearch(); + movementInputBlocked = onlinePicker.blocksMovementInput(); + return true; + } + commitPlayerInput(); + return true; + } + if (reservedPickerButton.contains(mx, my)) { + activeField = ActiveField.NONE; + onlinePicker.toggle(tab.pickerMode()); + movementInputBlocked = onlinePicker.blocksMovementInput(); + clearStatus(); + return true; + } + if (onlinePicker.isVisible() && onlinePicker.click(mx, my, button)) { + movementInputBlocked = onlinePicker.blocksMovementInput(); + return true; + } + if (onlinePicker.isVisible()) { + return false; + } + if (tab == RelationTab.STAFF) { + if (enabledToggle.contains(mx, my)) { + StaffHeuristicsConfig cfg = StaffHeuristicsConfig.get(); + cfg.setEnabled(!cfg.enabled()); + return true; + } + if (prefixInputRect.contains(mx, my)) { + activeField = ActiveField.PREFIX; + movementInputBlocked = true; + clearStatus(); + return true; + } + if (suffixInputRect.contains(mx, my)) { + activeField = ActiveField.SUFFIX; + movementInputBlocked = true; + clearStatus(); + return true; + } + if (containsInputRect.contains(mx, my)) { + activeField = ActiveField.CONTAINS; + movementInputBlocked = true; + clearStatus(); + return true; + } + if (prefixAddButton.contains(mx, my)) return commitHeuristic(ActiveField.PREFIX); + if (suffixAddButton.contains(mx, my)) return commitHeuristic(ActiveField.SUFFIX); + if (containsAddButton.contains(mx, my)) return commitHeuristic(ActiveField.CONTAINS); + for (ChipHit hit : chipHits) { + if (!hit.delete().contains(mx, my)) continue; + removeHeuristic(hit.kind(), hit.value()); + return true; + } + } + + for (CardEntryHit entryHit : cardHits) { + CardHit hit = entryHit.hit(); + if (inside(hit.delete(), mx, my)) { + tab.remove(entryHit.name()); + setStatus(tr("status.removed", "Removed: %s", entryHit.name())); + return true; + } + if (ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) { + return true; + } + } + + activeField = ActiveField.NONE; + return false; + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (onlinePicker.keyPressed(keyCode, scanCode, modifiers)) { + movementInputBlocked = activeField != ActiveField.NONE || onlinePicker.blocksMovementInput(); + return true; + } + if (activeField == ActiveField.NONE) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + activeField = ActiveField.NONE; + movementInputBlocked = false; + return true; + } + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + if (activeField == ActiveField.PLAYER) commitPlayerInput(); + else commitHeuristic(activeField); + return true; + } + if (keyCode == GLFW.GLFW_KEY_BACKSPACE) { + setFieldText(activeField, dropLast(fieldText(activeField))); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_V) { + appendToField(activeField, ClipboardUtil.get()); + return true; + } + return true; + } + + public boolean charTyped(char chr, int modifiers) { + if (onlinePicker.charTyped(chr, modifiers)) { + movementInputBlocked = activeField != ActiveField.NONE || onlinePicker.blocksMovementInput(); + return true; + } + if (activeField == ActiveField.NONE) return false; + if (chr >= 32 && chr != 127) { + appendToField(activeField, String.valueOf(chr)); + } + return true; + } + + private void renderToolbar(float x, float y, float w, float mx, float my, float scale, SettingsGuiPalette palette) { + float pillW = 165f * scale; + float pillH = 15f * scale; + float targetMode = tab.ordinal(); + float dt = AnimationUtility.deltaTime(); + modeAnim = AnimationUtility.approach(modeAnim, targetMode, dt, 12f); + modeAnim = AnimationUtility.snap(modeAnim, targetMode, 0.01f); + + friendsPill = new Rect(x, y, pillW / 3f, pillH); + enemiesPill = new Rect(x + pillW / 3f, y, pillW / 3f, pillH); + staffPill = new Rect(x + pillW * 2f / 3f, y, pillW / 3f, pillH); + friendsHoverAnim = updateHover(friendsHoverAnim, friendsPill.contains(mx, my), dt); + enemiesHoverAnim = updateHover(enemiesHoverAnim, enemiesPill.contains(mx, my), dt); + staffHoverAnim = updateHover(staffHoverAnim, staffPill.contains(mx, my), dt); + + ClickGuiRenderer.drawBlur(x, y, pillW, pillH, 4f * scale, palette.panelBlurTint(), 145f / 255f); + LayoutRender2D.roundedQuad(x, y, pillW, pillH, 3.5f * scale, + SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgLeft(), 0.34f), + SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgRight(), 0.44f), + SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgRight(), 0.44f), + SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgLeft(), 0.34f)); + LayoutRender2D.roundedStrokeQuad(x, y, pillW, pillH, 3.5f * scale, 0.5f * scale, + palette.panelStroke(), palette.moduleDividerEnd(), palette.moduleDividerEnd(), palette.panelStroke()); + + float pad = 1.2f * scale; + float segment = (pillW - pad * 2f) / 3f; + float activeX = x + pad + segment * modeAnim; + LayoutRender2D.roundedQuad(activeX, y + pad, segment, pillH - pad * 2f, 3f * scale, + SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedLeft(), 0.22f), + SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedRight(), 0.22f), + SettingsGuiPalette.mix(SettingsGuiPalette.darken(palette.panelPillActive(), 0.16f), palette.menuCategorySelectedRight(), 0.18f), + SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedLeft(), 0.22f)); + + drawToolbarSeparator(x + pad + segment, y, pillH, scale, palette); + drawToolbarSeparator(x + pad + segment * 2f, y, pillH, scale, palette); + + drawSegment(friendsPill, tr("tab.friends", "Friends"), tab == RelationTab.FRIENDS, friendsHoverAnim, scale, palette); + drawSegment(enemiesPill, tr("tab.enemies", "Enemies"), tab == RelationTab.ENEMIES, enemiesHoverAnim, scale, palette); + drawSegment(staffPill, tr("tab.staff", "Staff"), tab == RelationTab.STAFF, staffHoverAnim, scale, palette); + + float fieldX = x + pillW + 8f * scale; + float picker = 18f * scale; + float action = 18f * scale; + float gap = 4f * scale; + boolean onlineMode = onlinePicker.isOpen(); + float fieldW = Math.max(80f * scale, w - (fieldX - x) - picker - action - gap * 2f); + playerInputRect = new Rect(fieldX, y, fieldW, pillH); + addTypedButton = new Rect(fieldX + fieldW + gap, y, action, pillH); + reservedPickerButton = new Rect(addTypedButton.x() + action + gap, y, picker, pillH); + + if (onlineMode) { + String query = onlinePicker.searchText(); + drawInput(playerInputRect, query, tr("placeholder.search_online", "Search online"), onlinePicker.isSearchFocused(), mx, my, scale, palette); + addHoverAnim = updateHover(addHoverAnim, addTypedButton.contains(mx, my), dt); + drawIconButton(addTypedButton, "x", addHoverAnim, query == null || query.isBlank(), scale, palette); + } else { + drawInput(playerInputRect, playerInput, tr("placeholder.nick", "Nick"), activeField == ActiveField.PLAYER, mx, my, scale, palette); + addHoverAnim = updateHover(addHoverAnim, addTypedButton.contains(mx, my), dt); + drawIconButton(addTypedButton, "check", addHoverAnim, false, scale, palette); + } + pickerHoverAnim = updateHover(pickerHoverAnim, reservedPickerButton.contains(mx, my) || onlinePicker.isVisible(), dt); + drawIconButton(reservedPickerButton, onlineMode ? "x" : "user-plus", pickerHoverAnim, false, scale, palette); + } + + private void renderHeuristicsPanel(float x, float y, float w, float h, float mx, float my, float scale, SettingsGuiPalette palette) { + StaffHeuristicsConfig cfg = StaffHeuristicsConfig.get(); + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 190f / 255f); + LayoutRender2D.roundedQuad(x, y, w, h, 5f * scale, + palette.moduleCardTop(), palette.moduleCardTopStrong(), palette.moduleCardBottom(), palette.moduleCardBottomStrong()); + LayoutRender2D.roundedStroke(x, y, w, h, 5f * scale, 0.5f * scale, palette.menuWindowStroke()); + + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), tr("heuristics.title", "Staff heuristics"), x + 8f * scale, y + 6f * scale, 8f * scale, palette.moduleTitleText(), false); + enabledToggle = new Rect(x + w - 56f * scale, y + 5f * scale, 48f * scale, 14f * scale); + drawToggle(enabledToggle, cfg.enabled(), mx, my, scale, palette); + + float top = y + 25f * scale; + float gap = 7f * scale; + float colW = (w - 16f * scale - gap * 2f) / 3f; + renderHeuristicColumn(ActiveField.PREFIX, tr("heuristics.prefixes", "Prefixes"), cfg.prefixes(), x + 8f * scale, top, colW, h - 31f * scale, mx, my, scale, palette); + renderHeuristicColumn(ActiveField.SUFFIX, tr("heuristics.suffixes", "Suffixes"), cfg.suffixes(), x + 8f * scale + colW + gap, top, colW, h - 31f * scale, mx, my, scale, palette); + renderHeuristicColumn(ActiveField.CONTAINS, tr("heuristics.contains", "Contains"), cfg.contains(), x + 8f * scale + (colW + gap) * 2f, top, colW, h - 31f * scale, mx, my, scale, palette); + } + + private void renderHeuristicColumn(ActiveField kind, + String title, + Set entries, + float x, + float y, + float w, + float h, + float mx, + float my, + float scale, + SettingsGuiPalette palette) { + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), title, x, y, 6.7f * scale, palette.panelMuted(), false); + Rect field = new Rect(x, y + 10f * scale, w - 16f * scale, 14f * scale); + Rect add = new Rect(x + w - 14f * scale, y + 10f * scale, 14f * scale, 14f * scale); + if (kind == ActiveField.PREFIX) { + prefixInputRect = field; + prefixAddButton = add; + } else if (kind == ActiveField.SUFFIX) { + suffixInputRect = field; + suffixAddButton = add; + } else { + containsInputRect = field; + containsAddButton = add; + } + drawInput(field, fieldText(kind), tr("placeholder.rule", "Rule"), activeField == kind, mx, my, scale, palette); + drawIconButton(add, "check", add.contains(mx, my) ? 1f : 0f, false, scale, palette); + + float chipX = x; + float chipY = y + 29f * scale; + float chipH = 11f * scale; + List sorted = sorted(entries); + for (String entry : sorted) { + float textSize = 5.9f * scale; + String fitted = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterRegular(), entry, textSize, w - 14f * scale); + float chipW = Math.min(w, ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), fitted, textSize) + 16f * scale); + if (chipX + chipW > x + w + 0.1f) { + chipX = x; + chipY += chipH + 3f * scale; + } + if (chipY + chipH > y + h) break; + Rect chip = new Rect(chipX, chipY, chipW, chipH); + Rect del = new Rect(chipX + chipW - 10f * scale, chipY + 1f * scale, 9f * scale, 9f * scale); + LayoutRender2D.roundedQuad(chip.x(), chip.y(), chip.w(), chip.h(), 3f * scale, + LayoutRender2D.alpha(palette.panelPillBase(), 0.95f), + LayoutRender2D.alpha(palette.panelPillBase(), 0.88f), + LayoutRender2D.alpha(palette.panelPillBase(), 0.88f), + LayoutRender2D.alpha(palette.panelPillBase(), 0.95f)); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), fitted, chipX + 4f * scale, chipY + 3f * scale, textSize, palette.panelText(), false); + Renderer2D.COLOR.svg("x", del.x() + 1.8f * scale, del.y() + 1.8f * scale, 5.4f * scale, 5.4f * scale, + SvgRenderOptions.overrideColor(palette.panelMuted())); + chipHits.add(new ChipHit(kind, entry, del)); + chipX += chipW + 3f * scale; + } + } + + private void renderCards(float x, float y, float w, float h, List entries, float mx, float my, float scale, SettingsGuiPalette palette) { + float gap = 6f * scale; + float reservedScrollbarW = 9f * scale; + float cardAreaW = Math.max(1f, w - reservedScrollbarW); + int columns = cardAreaW > 230f * scale ? 2 : 1; + float cardW = (cardAreaW - gap * (columns - 1)) / columns; + float cardH = 36f * scale; + float stepY = cardH + gap; + int rows = (entries.size() + columns - 1) / columns; + float contentH = rows <= 0 ? 0f : rows * stepY - gap; + float maxScroll = Math.max(0f, contentH - h); + updateScrollbarMetrics(x, y, w, h, maxScroll, scale); + if (draggingScrollbar) scrollToMouse(ClickGuiRenderer.getMouseY()); + + scroll = ClickGuiMath.clamp(scroll, -maxScroll, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, draggingScrollbar ? 0.55f : 0.2f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, draggingScrollbar ? 0.01f : 0.05f); + + if (entries.isEmpty()) { + String empty = ClickGuiSearch.isActive() && !ClickGuiSearch.getText().isBlank() + ? tr("empty.no_matches", "No matching players.") + : tr("empty.none_added", "No players added."); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), empty, x + 4f * scale, y + 4f * scale, 7.4f * scale, palette.panelMuted(), false); + return; + } + + boolean clipped = ScissorFunction.pushRaw(x, y, w, h); + for (int i = 0; i < entries.size(); i++) { + int col = i % columns; + int row = i / columns; + float cx = x + col * (cardW + gap); + float cy = y + row * stepY + smoothedScroll; + if (cy + cardH < y || cy > y + h) continue; + String name = entries.get(i); + CardHit hit = cardComponent.render(name, cx, cy, cardW, cardH, mx, my, scale, palette); + cardHits.add(new CardEntryHit(name, hit)); + } + if (clipped) ScissorFunction.pop(); + renderScrollbar(x, y, w, h, maxScroll, scale, palette); + } + + private void drawSegment(Rect rect, String label, boolean active, float hover, float scale, SettingsGuiPalette palette) { + float size = 7.5f * scale; + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, size); + float th = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), size); + int color = active ? palette.panelText() : SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), 0.20f * hover); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), label, rect.x() + (rect.w() - tw) * 0.5f, rect.y() + (rect.h() - th) * 0.5f, size, color, false); + } + + private void drawToolbarSeparator(float x, float y, float h, float scale, SettingsGuiPalette palette) { + float backingW = Math.max(1.25f * scale, 1.05f); + float backingH = h - 4.4f * scale; + float backingY = y + (h - backingH) * 0.5f; + int backingTop = SettingsGuiPalette.withAlpha(palette.panelBgLeft(), 98); + int backingBottom = SettingsGuiPalette.withAlpha(palette.panelBgRight(), 78); + LayoutRender2D.roundedQuad( + x - backingW * 0.5f, + backingY, + backingW, + backingH, + backingW * 0.5f, + backingTop, + backingTop, + backingBottom, + backingBottom + ); + + float lineW = Math.max(0.5f * scale, 0.75f); + float lineH = h - 6.0f * scale; + float lineY = y + (h - lineH) * 0.5f; + int top = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerStart(), palette.menuCategorySelectedLeft(), 0.34f), 146); + int mid = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), palette.menuCategorySelectedRight(), 0.40f), 210); + int bottom = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), palette.panelText(), 0.24f), 136); + LayoutRender2D.roundedQuad( + x - lineW * 0.5f, + lineY, + lineW, + lineH, + lineW * 0.5f, + top, + mid, + bottom, + top + ); + } + + private void drawInput(Rect rect, String value, String placeholder, boolean active, float mx, float my, float scale, SettingsGuiPalette palette) { + boolean hover = rect.contains(mx, my); + int bgA = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverLeft(), hover || active ? 0.20f : 0.06f); + int bgB = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverRight(), hover || active ? 0.18f : 0.06f); + LayoutRender2D.roundedQuad(rect.x(), rect.y(), rect.w(), rect.h(), 3.5f * scale, bgA, bgB, bgB, bgA); + LayoutRender2D.roundedStrokeQuad(rect.x(), rect.y(), rect.w(), rect.h(), 3.5f * scale, 0.45f * scale, + active ? palette.moduleDividerEnd() : LayoutRender2D.alpha(palette.moduleDividerStart(), 0.75f), + active ? palette.moduleDividerEnd() : LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.75f), + active ? palette.moduleDividerEnd() : LayoutRender2D.alpha(palette.moduleDividerEnd(), 0.65f), + active ? palette.moduleDividerStart() : LayoutRender2D.alpha(palette.moduleDividerStart(), 0.75f)); + + boolean empty = value == null || value.isEmpty(); + String raw = empty ? (active ? "" : placeholder) : value; + int color = empty && !active ? palette.panelMuted() : palette.panelText(); + float size = 6.8f * scale; + String text = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterRegular(), raw, size, rect.w() - 8f * scale); + float textY = rect.y() + (rect.h() - ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterRegular(), size)) * 0.5f; + if (!text.isEmpty()) { + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), text, rect.x() + 4f * scale, textY, size, color, false); + } + if (active && (System.currentTimeMillis() / 500L) % 2L == 0L) { + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), text, size); + LayoutRender2D.rect(rect.x() + Math.min(tw + 5.5f * scale, rect.w() - 3f * scale), rect.y() + 3f * scale, 0.6f * scale, rect.h() - 6f * scale, palette.panelText()); + } + } + + private void drawIconButton(Rect rect, String icon, float hover, boolean disabled, float scale, SettingsGuiPalette palette) { + int bgA = disabled + ? LayoutRender2D.alpha(palette.panelPillBase(), 0.42f) + : SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverLeft(), 0.12f + hover * 0.22f); + int bgB = disabled + ? LayoutRender2D.alpha(palette.panelPillBase(), 0.32f) + : SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverRight(), 0.12f + hover * 0.24f); + LayoutRender2D.roundedQuad(rect.x(), rect.y(), rect.w(), rect.h(), 3.5f * scale, bgA, bgB, bgB, bgA); + int iconColor = disabled ? LayoutRender2D.alpha(palette.panelMuted(), 0.58f) : palette.menuCategoryText(); + float iconSize = Math.min(rect.w(), rect.h()) - 7f * scale; + Renderer2D.COLOR.svg(icon, rect.x() + (rect.w() - iconSize) * 0.5f, rect.y() + (rect.h() - iconSize) * 0.5f, + iconSize, iconSize, SvgRenderOptions.overrideColor(iconColor)); + } + + private void drawToggle(Rect rect, boolean enabled, float mx, float my, float scale, SettingsGuiPalette palette) { + boolean hover = rect.contains(mx, my); + int bg = enabled + ? SettingsGuiPalette.mix(palette.panelPillActive(), palette.menuCategorySelectedRight(), hover ? 0.22f : 0.12f) + : SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgRight(), hover ? 0.22f : 0.12f); + LayoutRender2D.roundedQuad(rect.x(), rect.y(), rect.w(), rect.h(), 4f * scale, bg, bg, SettingsGuiPalette.darken(bg, 0.10f), bg); + String label = enabled ? tr("heuristics.enabled", "Enabled") : tr("heuristics.disabled", "Disabled"); + float size = 6.2f * scale; + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), label, size); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterMedium(), label, rect.x() + (rect.w() - tw) * 0.5f, rect.y() + 4.1f * scale, size, + enabled ? palette.panelText() : palette.panelMuted(), false); + } + + private boolean switchTab(RelationTab next) { + if (tab != next) { + tab = next; + activeField = ActiveField.NONE; + resetScroll(); + if (onlinePicker.isVisible()) { + onlinePicker.open(tab.pickerMode()); + } + clearStatus(); + } + return true; + } + + private void commitPlayerInput() { + String cleaned = ChatNameUtil.normalizeNickCandidate(playerInput); + if (!ChatNameUtil.isNickLike(cleaned)) { + setStatus(tr("status.invalid_nick", "Invalid nick")); + return; + } + if (tab.add(cleaned)) { + playerInput = ""; + activeField = ActiveField.NONE; + movementInputBlocked = false; + setStatus(tr("status.added", "Added: %s", cleaned)); + } else { + setStatus(tr("status.already_exists", "Already exists: %s", cleaned)); + } + } + + private boolean commitHeuristic(ActiveField kind) { + String value = StaffHeuristicsConfig.cleanRule(fieldText(kind)); + if (value.isBlank()) { + setStatus(tr("status.rule_empty", "Rule is empty")); + return true; + } + boolean changed = switch (kind) { + case PREFIX -> StaffHeuristicsConfig.get().addPrefix(value); + case SUFFIX -> StaffHeuristicsConfig.get().addSuffix(value); + case CONTAINS -> StaffHeuristicsConfig.get().addContains(value); + default -> false; + }; + if (changed) { + setFieldText(kind, ""); + activeField = ActiveField.NONE; + movementInputBlocked = false; + setStatus(tr("status.rule_added", "Rule added")); + } else { + setStatus(tr("status.rule_exists", "Rule already exists")); + } + return true; + } + + private void removeHeuristic(ActiveField kind, String value) { + boolean changed = switch (kind) { + case PREFIX -> StaffHeuristicsConfig.get().removePrefix(value); + case SUFFIX -> StaffHeuristicsConfig.get().removeSuffix(value); + case CONTAINS -> StaffHeuristicsConfig.get().removeContains(value); + default -> false; + }; + if (changed) setStatus(tr("status.rule_removed", "Rule removed")); + } + + private List filteredEntries(List entries) { + if (!ClickGuiSearch.isActive() || ClickGuiSearch.getText().isBlank()) return entries; + String q = ClickGuiSearch.getText().toLowerCase(Locale.ROOT); + List out = new ArrayList<>(); + for (String entry : entries) { + if (entry != null && entry.toLowerCase(Locale.ROOT).contains(q)) out.add(entry); + } + return out; + } + + private static List sorted(Set src) { + List out = new ArrayList<>(); + if (src != null) { + for (String value : src) { + if (value != null && !value.isBlank()) out.add(value); + } + } + out.sort(String.CASE_INSENSITIVE_ORDER); + return out; + } + + private static boolean inside(RelationPlayerCardComponent.ActionHit hit, float mx, float my) { + return hit != null && ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h()); + } + + private void appendToField(ActiveField field, String raw) { + if (raw == null || raw.isEmpty()) return; + String current = fieldText(field); + int max = field == ActiveField.PLAYER ? 32 : 48; + String next = current + raw; + if (next.length() > max) next = next.substring(0, max); + setFieldText(field, next); + } + + private String fieldText(ActiveField field) { + return switch (field) { + case PLAYER -> playerInput; + case PREFIX -> prefixInput; + case SUFFIX -> suffixInput; + case CONTAINS -> containsInput; + default -> ""; + }; + } + + private void setFieldText(ActiveField field, String value) { + String next = value == null ? "" : value; + switch (field) { + case PLAYER -> playerInput = next; + case PREFIX -> prefixInput = next; + case SUFFIX -> suffixInput = next; + case CONTAINS -> containsInput = next; + default -> { + } + } + } + + private static String dropLast(String text) { + if (text == null || text.isEmpty()) return ""; + return text.substring(0, text.length() - 1); + } + + private float updateHover(float current, boolean hovered, float dt) { + return AnimationUtility.snap(AnimationUtility.approach(current, hovered ? 1f : 0f, dt, 12f), hovered ? 1f : 0f, 0.01f); + } + + private void renderStatus(float x, float y, float w, float scale, SettingsGuiPalette palette) { + if (statusMessage == null || statusMessage.isBlank()) return; + if (System.currentTimeMillis() > statusUntilMs) { + clearStatus(); + return; + } + float size = 6.8f * scale; + String text = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterRegular(), statusMessage, size, w - 8f * scale); + ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), text, x + 4f * scale, y + 2.6f * scale, size, palette.panelMuted(), false); + } + + private void setStatus(String message) { + statusMessage = message; + statusUntilMs = System.currentTimeMillis() + STATUS_DURATION_MS; + } + + private static String tr(String key, String fallback, Object... args) { + return ClickGuiI18n.tr(I18N + key, fallback, args); + } + + private void clearStatus() { + statusMessage = null; + statusUntilMs = 0L; + } + + private void renderScrollbar(float x, float y, float w, float h, float maxScroll, float scale, SettingsGuiPalette palette) { + updateScrollbarMetrics(x, y, w, h, maxScroll, scale); + if (!scrollbarVisible) return; + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + if (isScrollbarHovered(mx, my) || draggingScrollbar) SystemCursor.set(SystemCursor.CursorType.SCROLL); + + LayoutRender2D.roundedQuad(scrollbarX, scrollbarY, scrollbarW, scrollbarH, 2f * scale, + palette.moduleScrollTrackA(), palette.moduleScrollTrackB(), palette.moduleScrollTrackB(), palette.moduleScrollTrackA()); + LayoutRender2D.roundedQuad(scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH, 2f * scale, + palette.moduleScrollHandleA(), palette.moduleScrollHandleB(), palette.moduleScrollHandleB(), palette.moduleScrollHandleA()); + } + + private void updateScrollbarMetrics(float x, float y, float w, float h, float maxScroll, float scale) { + scrollbarVisible = maxScroll > 0.5f; + scrollbarMaxScroll = Math.max(0f, maxScroll); + if (!scrollbarVisible) { + draggingScrollbar = false; + scrollbarX = scrollbarY = scrollbarW = scrollbarH = scrollbarThumbY = scrollbarThumbH = 0f; + return; + } + scrollbarW = 3f * scale; + scrollbarX = x + w - scrollbarW - 2f * scale; + scrollbarY = y + 3f * scale; + scrollbarH = Math.max(1f, h - 6f * scale); + scrollbarThumbH = Math.max(18f * scale, scrollbarH * (scrollbarH / (scrollbarMaxScroll + scrollbarH))); + float scrollRatio = scrollbarMaxScroll <= 0f ? 0f : (-smoothedScroll / scrollbarMaxScroll); + scrollbarThumbY = scrollbarY + (scrollbarH - scrollbarThumbH) * AnimationUtility.clamp(scrollRatio, 0f, 1f); + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 4f; + return ClickGuiMath.insideRect(mx, my, scrollbarX - pad, scrollbarY - pad, scrollbarW + pad * 2f, scrollbarH + pad * 2f); + } + + private void scrollToMouse(float my) { + if (!scrollbarVisible || scrollbarMaxScroll <= 0f) return; + float span = scrollbarH - scrollbarThumbH; + if (span <= 0.5f) return; + float thumbTop = AnimationUtility.clamp(my - scrollbarDragOffset, scrollbarY, scrollbarY + span); + float ratio = (thumbTop - scrollbarY) / span; + scroll = -scrollbarMaxScroll * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private enum ActiveField { + NONE, + PLAYER, + PREFIX, + SUFFIX, + CONTAINS + } + + private enum RelationTab { + FRIENDS, + ENEMIES, + STAFF; + + private List entries() { + PlayerRelations rel = PlayerRelations.get(); + return switch (this) { + case FRIENDS -> sorted(rel.getFriends()); + case ENEMIES -> sorted(rel.getEnemies()); + case STAFF -> sorted(rel.getStaff()); + }; + } + + private boolean add(String name) { + PlayerRelations rel = PlayerRelations.get(); + boolean changed = switch (this) { + case FRIENDS -> rel.addFriend(name); + case ENEMIES -> rel.addEnemy(name); + case STAFF -> rel.addStaff(name); + }; + if (changed) rel.save(); + return changed; + } + + private boolean remove(String name) { + PlayerRelations rel = PlayerRelations.get(); + boolean changed = switch (this) { + case FRIENDS -> rel.removeFriend(name); + case ENEMIES -> rel.removeEnemy(name); + case STAFF -> rel.removeStaff(name); + }; + if (changed) rel.save(); + return changed; + } + + private DefineTarget.RelationTargetMode pickerMode() { + return switch (this) { + case FRIENDS -> DefineTarget.RelationTargetMode.FRIEND; + case ENEMIES -> DefineTarget.RelationTargetMode.ENEMY; + case STAFF -> DefineTarget.RelationTargetMode.STAFF; + }; + } + } + + private record CardEntryHit(String name, CardHit hit) { + } + + private record ChipHit(ActiveField kind, String value, Rect delete) { + } + + private record Rect(float x, float y, float w, float h) { + static final Rect ZERO = new Rect(0f, 0f, 0f, 0f); + + boolean contains(float mx, float my) { + return ClickGuiMath.insideRect(mx, my, x, y, w, h); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeComponent.java new file mode 100644 index 0000000..fbf4489 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeComponent.java @@ -0,0 +1,569 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.theme; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiSearch; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiMath; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class ThemeComponent { + private static final String ADD_CARD_LABEL = "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u0435\u043c\u0443"; + + private final List hits = new ArrayList<>(); + private final Map swatchHoverByKey = new HashMap<>(); + private final Map selectedAnimByTheme = new HashMap<>(); + private final Set liveThemeIds = new HashSet<>(); + private float scroll; + private float smoothedScroll; + private boolean scrollbarVisible; + private boolean draggingScrollbar; + private float scrollbarX; + private float scrollbarY; + private float scrollbarW; + private float scrollbarH; + private float scrollbarThumbY; + private float scrollbarThumbH; + private float scrollbarMaxScroll; + private float scrollbarDragOffset; + + private static void drawThemeGradientRect(float x, + float y, + float w, + float h, + float radius, + Themes.GradientSpec gradient, + int fallbackStart, + int fallbackEnd) { + if (gradient != null && gradient.enabled()) { + int start = SettingsGuiPalette.withAlpha(gradient.start(), 220); + int end = SettingsGuiPalette.withAlpha(gradient.end(), 220); + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, radius, start, end, gradient.angleDeg()); + return; + } + int start = SettingsGuiPalette.withAlpha(fallbackStart, 220); + int end = SettingsGuiPalette.withAlpha(fallbackEnd, 220); + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, radius, start, end, 0f); + } + + private static List filteredEntries() { + List all = Theme.themes(); + if (!ClickGuiSearch.isActive() || ClickGuiSearch.getText().isEmpty()) return all; + List out = new ArrayList<>(); + for (Themes.ThemeEntry entry : all) { + if (ClickGuiSearch.matches(entry.name()) || ClickGuiSearch.matches(entry.getId())) { + out.add(entry); + } + } + return out; + } + + private static boolean shouldShowAddCard() { + if (!ClickGuiSearch.isActive() || ClickGuiSearch.getText().isEmpty()) return true; + return ClickGuiSearch.matches(ADD_CARD_LABEL); + } + + public void resetScroll() { + scroll = 0f; + smoothedScroll = 0f; + draggingScrollbar = false; + } + + public void scroll(float mx, float my, float menuX, float menuY, float menuW, float menuH, double amount, float scale) { + float x = menuX + 31f * scale; + float y = menuY + 33f * scale; + float w = menuW - 42f * scale; + float h = menuH - 39f * scale; + if (!ClickGuiMath.insideRect(mx, my, x, y, w, h)) return; + scroll += (float) (amount * 22f); + } + + public boolean mousePressedScrollbar(float mx, float my, int button) { + if (button != 0) return false; + if (!isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollbarDragOffset = ClickGuiMath.insideRect(mx, my, scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH) + ? my - scrollbarThumbY + : scrollbarThumbH * 0.5f; + scrollToMouse(my); + return true; + } + + public void mouseReleased(int button) { + if (button == 0) { + draggingScrollbar = false; + } + } + + public ThemeAction click(float mx, float my, int button) { + if (isScrollbarHovered(mx, my)) return null; + if (button != 0 && button != 1) return null; + for (Hit hit : hits) { + if (!ClickGuiMath.insideRect(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + if (hit.deleteButton()) return button == 0 && hit.custom() ? new ThemeAction(ThemeActionType.DELETE, hit.themeId()) : null; + if (hit.addCard()) return button == 0 ? new ThemeAction(ThemeActionType.ADD, null) : null; + if (hit.themeId() == null || hit.themeId().isBlank()) return null; + if (button == 1 && hit.custom()) return new ThemeAction(ThemeActionType.EDIT, hit.themeId()); + if (button == 0) return new ThemeAction(ThemeActionType.SELECT, hit.themeId()); + } + return null; + } + + public void render(float menuX, float menuY, float menuW, float menuH, float mx, float my, float scale) { + hits.clear(); + + SettingsGuiPalette palette = SettingsGuiPalette.current(); + updateSelectedAnimations(Theme.themes()); + List entries = filteredEntries(); + boolean includeAddCard = shouldShowAddCard(); + int cardsTotal = entries.size() + (includeAddCard ? 1 : 0); + if (cardsTotal <= 0) return; + + float areaX = menuX + 31f * scale; + float areaY = menuY + 33f * scale; + float areaW = menuW - 42f * scale; + float areaH = menuH - 39f * scale; + + int columns = 2; + float gap = 6f * scale; + float cardW = (areaW - gap * (columns - 1)) / columns; + float cardH = 46f * scale; + float stepY = cardH + gap; + + int rows = (cardsTotal + columns - 1) / columns; + float contentH = rows * stepY - gap; + float maxScroll = Math.max(0f, contentH - areaH); + updateScrollbarMetrics(menuX, menuY, menuH, areaH, contentH, maxScroll, scale); + if (draggingScrollbar) { + scrollToMouse(my); + } + + scroll = ClickGuiMath.clamp(scroll, -maxScroll, 0f); + smoothedScroll = AnimationUtility.approach(smoothedScroll, scroll, draggingScrollbar ? 0.55f : 0.2f); + smoothedScroll = AnimationUtility.snap(smoothedScroll, scroll, draggingScrollbar ? 0.01f : 0.05f); + boolean clipped = ScissorFunction.pushRaw(areaX, areaY, areaW, areaH); + + int idx = 0; + for (Themes.ThemeEntry entry : entries) { + int col = idx % columns; + int row = idx / columns; + float x = areaX + col * (cardW + gap); + float y = areaY + row * stepY + smoothedScroll; + if (y + cardH < areaY || y > areaY + areaH) { + idx++; + continue; + } + boolean custom = !entry.builtin(); + drawThemeCard(entry, x, y, cardW, cardH, mx, my, scale, palette, selectedAnim(entry.getId())); + if (custom) { + float deleteSize = deleteButtonSize(scale); + hits.add(new Hit(deleteButtonX(x, cardW, scale), deleteButtonY(y, scale), deleteSize, deleteSize, entry.getId(), false, true, true)); + } + hits.add(new Hit(x, y, cardW, cardH, entry.getId(), false, custom, false)); + idx++; + } + + if (includeAddCard) { + int col = idx % columns; + int row = idx / columns; + float x = areaX + col * (cardW + gap); + float y = areaY + row * stepY + smoothedScroll; + if (!(y + cardH < areaY || y > areaY + areaH)) { + drawAddCard(x, y, cardW, cardH, scale, palette); + hits.add(new Hit(x, y, cardW, cardH, null, true, false, false)); + } + } + if (clipped) ScissorFunction.pop(); + renderScrollbar(menuX, menuY, menuH, areaH, contentH, maxScroll, scale, palette); + } + + private void updateSelectedAnimations(List entries) { + String currentId = Theme.currentId(); + float dt = AnimationUtility.deltaTime(); + liveThemeIds.clear(); + + for (Themes.ThemeEntry entry : entries) { + String id = entry.getId(); + liveThemeIds.add(id); + float target = id.equals(currentId) ? 1f : 0f; + float value = selectedAnimByTheme.getOrDefault(id, target); + value = AnimationUtility.approach(value, target, dt, target > value ? 11.5f : 9.5f); + value = AnimationUtility.snap(value, target, 0.002f); + selectedAnimByTheme.put(id, value); + } + + selectedAnimByTheme.keySet().removeIf(id -> !liveThemeIds.contains(id)); + } + + private float selectedAnim(String themeId) { + return selectedAnimByTheme.getOrDefault(themeId, themeId.equals(Theme.currentId()) ? 1f : 0f); + } + + private void drawThemeCard(Themes.ThemeEntry entry, + float x, + float y, + float w, + float h, + float mx, + float my, + float scale, + SettingsGuiPalette palette, + float selectedAnim) { + Themes.Theme t = entry.theme(); + float selected = AnimationUtility.easeInOutCubic(selectedAnim); + int selectedAccent = SettingsGuiPalette.withAlpha(t.accent(), Math.round(82f * selected)); + int selectedAccentSoft = SettingsGuiPalette.withAlpha(t.accentSoft(), Math.round(48f * selected)); + + if (selected > 0.001f) { + LayoutRender2D.roundedSoftShadow( + x - 0.8f * scale, + y - 0.8f * scale, + w + 1.6f * scale, + h + 1.6f * scale, + 5.8f * scale, + (5.5f + 2.5f * selected) * scale, + 0.025f * selected, + SettingsGuiPalette.withAlpha(t.accent(), Math.round(72f * selected)) + ); + } + + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, + y, + w, + h, + 5f * scale, + palette.moduleCardTop(), + palette.moduleCardTopStrong(), + palette.moduleCardBottom(), + palette.moduleCardBottomStrong() + ); + if (selected > 0.001f) { + LayoutRender2D.roundedQuad( + x, + y, + w, + h, + 5f * scale, + selectedAccent, + selectedAccentSoft, + SettingsGuiPalette.withAlpha(t.accentSoft(), Math.round(24f * selected)), + SettingsGuiPalette.withAlpha(t.accent(), Math.round(38f * selected)) + ); + LayoutRender2D.roundedStrokeQuad( + x + 0.35f * scale, + y + 0.35f * scale, + w - 0.7f * scale, + h - 0.7f * scale, + 4.7f * scale, + (0.55f + 0.35f * selected) * scale, + SettingsGuiPalette.withAlpha(t.accent(), Math.round(235f * selected)), + SettingsGuiPalette.withAlpha(t.accentSoft(), Math.round(190f * selected)), + SettingsGuiPalette.withAlpha(t.accentSoft(), Math.round(115f * selected)), + SettingsGuiPalette.withAlpha(t.accent(), Math.round(180f * selected)) + ); + + } + LayoutRender2D.rectQuad( + x, + y + 15.5f * scale, + w, + 0.5f * scale, + SettingsGuiPalette.mix(palette.moduleDividerStart(), SettingsGuiPalette.withAlpha(t.accent(), 210), selected * 0.78f), + SettingsGuiPalette.mix(palette.moduleDividerEnd(), SettingsGuiPalette.withAlpha(t.accentSoft(), 190), selected * 0.72f), + SettingsGuiPalette.mix(palette.moduleDividerEnd(), SettingsGuiPalette.withAlpha(t.accentSoft(), 145), selected * 0.58f), + SettingsGuiPalette.mix(palette.moduleDividerStart(), SettingsGuiPalette.withAlpha(t.accent(), 175), selected * 0.68f) + ); + + boolean custom = !entry.builtin(); + float titleSize = 9.2f * scale; + float titleReserve = custom ? 40f * scale : 12f * scale; + String title = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterMedium(), entry.name(), titleSize, w - titleReserve); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterMedium(), + title, + x + 6f * scale, + y + 3.8f * scale, + titleSize, + SettingsGuiPalette.mix(0xFFFFFFFF, SettingsGuiPalette.withAlpha(t.accent(), 255), selected * 0.24f), + false + ); + if (custom) { + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), title, titleSize); + float iconSize = 7f * scale; + float iconX = Math.min(x + w - 28f * scale, x + 7f * scale + tw + 3f * scale); + Renderer2D.COLOR.svg("paintbrush", iconX, y + 5.3f * scale, iconSize, iconSize, SvgRenderOptions.overrideColor(SettingsGuiPalette.withAlpha(t.accent(), 230))); + drawCardDeleteButton(x, y, w, mx, my, scale, palette); + } + + float swatchPadX = 6f * scale; + float chipsX = x + swatchPadX; + float chipsY = y + 20f * scale; + float chipsW = w - swatchPadX * 2f; + float chipsBottom = y + h - 5f * scale; + float chipsH = Math.max(2f * scale, chipsBottom - chipsY); + + Themes.GradientSpec[] gradients = new Themes.GradientSpec[]{ + entry.windowGradient(), + entry.cardGradient(), + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + }; + int[] colorA = new int[]{ + t.windowBg(), + t.cardEnabled(), + t.windowHeader(), + t.surface(), + t.surfaceHover(), + t.accent(), + t.accentSoft(), + t.windowStroke(), + t.strokeSoft(), + t.textPrimary(), + t.textMuted(), + t.windowBg() + }; + int[] colorB = new int[]{ + t.windowHeader(), + t.accentSoft(), + t.windowStroke(), + t.surfaceHover(), + t.windowHeader(), + t.accentSoft(), + t.strokeSoft(), + t.strokeSoft(), + t.textMuted(), + t.textMuted(), + t.surface(), + t.accentSoft() + }; + + int columns = colorA.length; + float chipSize = Math.min(10.2f * scale, chipsH); + float minGap = scale; + if (columns > 1) { + float maxChipFromWidth = (chipsW - minGap * (columns - 1)) / columns; + chipSize = Math.min(chipSize, maxChipFromWidth); + } + chipSize = Math.max(6f * scale, chipSize); + float chipGap = columns <= 1 ? 0f : Math.max(minGap, (chipsW - chipSize * columns) / (columns - 1)); + float hoverBoost = 0.75f * scale; + float yCenterOffset = (chipsH - chipSize) * 0.5f; + + for (int i = 0; i < colorA.length; i++) { + int col = i % columns; + int row = i / columns; + float cx = chipsX + col * (chipSize + chipGap); + float cy = chipsY + yCenterOffset + row * (chipSize + chipGap); + boolean hover = ClickGuiMath.insideRect(mx, my, cx, cy, chipSize, chipSize); + String key = entry.getId() + "#" + i; + float anim = swatchHoverByKey.getOrDefault(key, 0f); + anim = AnimationUtility.approach(anim, hover ? 1f : 0f, 0.20f); + anim = AnimationUtility.snap(anim, hover ? 1f : 0f, 0.01f); + swatchHoverByKey.put(key, anim); + + float size = chipSize + hoverBoost * anim; + float ox = cx - (size - chipSize) * 0.5f; + float oy = cy - (size - chipSize) * 0.5f; + + drawThemeGradientRect( + ox, + oy, + size, + size, + 2.3f * scale, + gradients[i], + colorA[i], + colorB[i] + ); + + // TODO: Show swatch tooltip on hover (name/value), once UX spec is provided. + } + } + + private static float deleteButtonSize(float scale) { + return 12f * scale; + } + + private static float deleteButtonX(float x, float w, float scale) { + return x + w - 16f * scale; + } + + private static float deleteButtonY(float y, float scale) { + return y + 2.2f * scale; + } + + private void drawCardDeleteButton(float x, + float y, + float w, + float mx, + float my, + float scale, + SettingsGuiPalette palette) { + float size = deleteButtonSize(scale); + float bx = deleteButtonX(x, w, scale); + float by = deleteButtonY(y, scale); + boolean hover = ClickGuiMath.insideRect(mx, my, bx, by, size, size); + int bg = hover + ? SettingsGuiPalette.withAlpha(0xFFE5484D, 64) + : SettingsGuiPalette.withAlpha(palette.moduleCardBottomStrong(), 90); + int stroke = hover + ? SettingsGuiPalette.withAlpha(0xFFFF6B6F, 160) + : SettingsGuiPalette.withAlpha(palette.moduleDividerEnd(), 80); + LayoutRender2D.roundedQuad(bx, by, size, size, 3.5f * scale, bg, bg, bg, bg); + LayoutRender2D.roundedStroke(bx, by, size, size, 3.5f * scale, 0.45f * scale, stroke); + Renderer2D.COLOR.svg( + "trash-2", + bx + 2.4f * scale, + by + 2.3f * scale, + 7.2f * scale, + 7.2f * scale, + SvgRenderOptions.overrideColor(SettingsGuiPalette.withAlpha(hover ? 0xFFFFC4C7 : 0xFFFFFFFF, hover ? 245 : 160)) + ); + } + + private void drawAddCard(float x, float y, float w, float h, float scale, SettingsGuiPalette palette) { + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * scale, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedQuad( + x, + y, + w, + h, + 5f * scale, + palette.moduleCardTop(), + palette.moduleCardTopStrong(), + palette.moduleCardBottom(), + palette.moduleCardBottomStrong() + ); + + float plusSize = 11f * scale; + float plusW = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterMedium(), "+", plusSize); + float plusH = ClickGuiRenderer.textHeight(ClickGuiRenderer.getInterMedium(), plusSize); + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterMedium(), + "+", + x + (w - plusW) * 0.5f, + y + 5.5f * scale, + plusSize, + palette.panelText(), + false + ); + + float titleSize = 7.6f * scale; + String title = ClickGuiRenderer.fitText(ClickGuiRenderer.getInterRegular(), ADD_CARD_LABEL, titleSize, w - 12f * scale); + float tw = ClickGuiRenderer.textWidth(ClickGuiRenderer.getInterRegular(), title, titleSize); + float ty = y + h - plusH - 10f * scale; + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + title, + x + (w - tw) * 0.5f, + ty, + titleSize, + palette.moduleDescriptionText(), + false + ); + } + + private void renderScrollbar(float menuX, float menuY, float menuH, float areaH, float contentH, float maxScroll, float scale, SettingsGuiPalette palette) { + updateScrollbarMetrics(menuX, menuY, menuH, areaH, contentH, maxScroll, scale); + if (!scrollbarVisible) return; + + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + boolean hover = isScrollbarHovered(mx, my); + if (hover || draggingScrollbar) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + + LayoutRender2D.roundedQuad( + scrollbarX, scrollbarY, scrollbarW, scrollbarH, 2f * scale, + palette.moduleScrollTrackA(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackA() + ); + LayoutRender2D.roundedQuad( + scrollbarX, scrollbarThumbY, scrollbarW, scrollbarThumbH, 2f * scale, + palette.moduleScrollHandleA(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleA() + ); + } + + private void updateScrollbarMetrics(float menuX, float menuY, float menuH, float areaH, float contentH, float maxScroll, float scale) { + scrollbarVisible = maxScroll > 0f && contentH > areaH + 0.5f; + scrollbarMaxScroll = Math.max(0f, maxScroll); + if (!scrollbarVisible) { + draggingScrollbar = false; + scrollbarX = scrollbarY = scrollbarW = scrollbarH = scrollbarThumbY = scrollbarThumbH = 0f; + return; + } + + scrollbarW = 4f * scale; + scrollbarX = menuX + 400f * scale - 35f * scale - scrollbarW + 50f * scale; + scrollbarY = menuY + 33f * scale; + scrollbarH = menuH - 58f * scale; + scrollbarThumbH = Math.max(20f * scale, scrollbarH * (areaH / contentH)); + float ratio = scrollbarMaxScroll <= 0f ? 0f : (-smoothedScroll / scrollbarMaxScroll); + scrollbarThumbY = scrollbarY + (scrollbarH - scrollbarThumbH) * AnimationUtility.clamp(ratio, 0f, 1f); + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 4f; + return ClickGuiMath.insideRect(mx, my, scrollbarX - pad, scrollbarY - pad, scrollbarW + pad * 2f, scrollbarH + pad * 2f); + } + + private void scrollToMouse(float my) { + if (!scrollbarVisible || scrollbarMaxScroll <= 0f) return; + float span = scrollbarH - scrollbarThumbH; + if (span <= 0.5f) return; + float thumbTop = AnimationUtility.clamp(my - scrollbarDragOffset, scrollbarY, scrollbarY + span); + float ratio = (thumbTop - scrollbarY) / span; + scroll = -scrollbarMaxScroll * AnimationUtility.clamp(ratio, 0f, 1f); + } + + public enum ThemeActionType { + SELECT, + ADD, + EDIT, + DELETE + } + + public record ThemeAction(ThemeActionType type, String themeId) { + } + + private record Hit(float x, float y, float w, float h, String themeId, boolean addCard, boolean custom, boolean deleteButton) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeEditorPreviewComponent.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeEditorPreviewComponent.java new file mode 100644 index 0000000..405a03d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/implement/theme/ThemeEditorPreviewComponent.java @@ -0,0 +1,187 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.theme; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.features.theme.EditableClickGuiTheme; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import net.minecraft.client.Minecraft; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ThemeEditorPreviewComponent { + private static final String PREVIEW_LAYOUT_ID = "combatant:api/clickgui/theme_preview"; + private static final float DESIGN_W = 320f; + private static final float DESIGN_H = 190f; + + private final Minecraft mc = Minecraft.getInstance(); + private final UiScriptModuleHandle previewModuleHandle = HudScriptLayouts.handle(PREVIEW_LAYOUT_ID); + private final CachedUiScriptRuntime previewRuntime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); + private float previewAnim; + + public void reset() { + previewAnim = 0f; + previewRuntime.reset(); + } + + public void render(EditableClickGuiTheme editor, + float menuX, + float menuY, + float menuW, + float menuH, + float mx, + float my, + float scale) { + if (editor == null) { + previewAnim = AnimationUtility.approach(previewAnim, 0f, AnimationUtility.deltaTime(), 12f); + return; + } + + float dt = AnimationUtility.deltaTime(); + previewAnim = AnimationUtility.approach(previewAnim, 1f, dt, 12f); + previewAnim = AnimationUtility.snap(previewAnim, 1f, 0.01f); + float alpha = AnimationUtility.easeInOutCubic(previewAnim); + if (alpha <= 0.01f) return; + + float safePad = 22f * scale; + float availableW = Math.max(1f, menuW - safePad * 2f); + float availableH = Math.max(1f, menuH - safePad * 2f); + float unit = Math.min(scale, Math.min(availableW / DESIGN_W, availableH / DESIGN_H)); + if (unit <= 0.05f) return; + + float panelW = DESIGN_W * unit; + float panelH = DESIGN_H * unit; + float panelX = menuX + (menuW - panelW) * 0.5f; + float panelY = menuY + (menuH - panelH) * 0.5f; + + Themes.ThemeEntry entry = editor.toEntry(); + if (entry == null || entry.theme() == null) return; + + boolean clip = ScissorFunction.pushRaw(panelX, panelY, panelW, panelH); + try { + renderScripted(entry, panelX, panelY, panelW, panelH, unit, alpha); + } finally { + if (clip) ScissorFunction.pop(); + } + } + + public boolean mouseClicked(EditableClickGuiTheme editor, float mx, float my, int button) { + return false; + } + + private void renderScripted(Themes.ThemeEntry entry, + float panelX, + float panelY, + float panelW, + float panelH, + float unit, + float alpha) { + if (previewModuleHandle.isRuntimeBlocked() || mc == null || mc.getResourceManager() == null) return; + HudScriptLayouts.pollReloadCombo(mc); + if (previewModuleHandle.consumeChanged()) { + previewRuntime.reset(); + } + UiScriptModule module = ensurePreviewModule(); + if (module == null) return; + + Map props = previewTemplateProps(entry, panelW, panelH, unit, alpha); + long treeSignature = CachedUiScriptRuntime.signature(props); + long layoutSignature = CachedUiScriptRuntime.mix( + CachedUiScriptRuntime.mix(CachedUiScriptRuntime.mix(23L, panelX), panelY), + CachedUiScriptRuntime.mix(CachedUiScriptRuntime.mix(37L, panelW), panelH) + ); + + UiRuntime runtime = previewRuntime.updatePersistent( + previewModuleHandle, + module, + "theme-preview", + treeSignature, + layoutSignature, + panelW, + panelH, + ClickGuiRenderer.getInterRegular(), + panelX, + panelY, + panelW, + panelH, + () -> props, + () -> Collections.>emptyMap() + ); + if (runtime == null) return; + ClickGuiRenderer.flushRenderer(); + runtime.render(new UiRenderContext(Renderer2D.COLOR, ClickGuiRenderer.getInterRegular(), null, 0f, UiProjectionMode.CURRENT, alpha)); + ClickGuiRenderer.flushRenderer(); + } + + private UiScriptModule ensurePreviewModule() { + if (!previewModuleHandle.ensureLoaded(mc.getResourceManager())) { + HudScriptLayouts.reportLoadError(previewModuleHandle); + return null; + } + previewModuleHandle.consumeChanged(); + return previewModuleHandle.module(); + } + + private Map previewTemplateProps(Themes.ThemeEntry entry, + float width, + float height, + float unit, + float alpha) { + Themes.Theme theme = entry.theme(); + Map props = new LinkedHashMap<>(); + props.put("width", width); + props.put("height", height); + props.put("unit", unit); + props.put("alpha", alpha); + props.put("entryName", entry.name() == null || entry.name().isBlank() ? "Custom Theme" : entry.name()); + + props.put("windowBg", hex(theme.windowBg())); + props.put("windowHeader", hex(theme.windowHeader())); + props.put("windowStroke", hex(theme.windowStroke())); + props.put("surface", hex(theme.surface())); + props.put("surfaceHover", hex(theme.surfaceHover())); + props.put("cardEnabled", hex(theme.cardEnabled())); + props.put("cardDisabled", hex(theme.cardDisabled())); + props.put("textPrimary", hex(theme.textPrimary())); + props.put("textMuted", hex(theme.textMuted())); + props.put("accent", hex(theme.accent())); + props.put("accentSoft", hex(theme.accentSoft())); + props.put("strokeSoft", hex(theme.strokeSoft())); + + putGradient(props, "windowGradient", entry.windowGradient()); + putGradient(props, "headerGradient", entry.headerGradient()); + putGradient(props, "surfaceGradient", entry.surfaceGradient()); + putGradient(props, "cardGradient", entry.cardGradient()); + putGradient(props, "strokeGradient", entry.strokeGradient()); + return props; + } + + private static void putGradient(Map props, String prefix, Themes.GradientSpec gradient) { + boolean enabled = gradient != null && gradient.enabled(); + props.put(prefix + "Enabled", enabled); + props.put(prefix + "Start", hex(gradient != null ? gradient.start() : 0xFFFFFFFF)); + props.put(prefix + "End", hex(gradient != null ? gradient.end() : 0xFFFFFFFF)); + props.put(prefix + "Angle", gradient != null ? gradient.angleDeg() : 90f); + } + + private static String hex(int argb) { + return "#" + String.format("%08X", argb); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/render/LayoutRender2D.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/render/LayoutRender2D.java new file mode 100644 index 0000000..d4a3261 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/settings/render/LayoutRender2D.java @@ -0,0 +1,118 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; + +public enum LayoutRender2D { + ; + private static final float SOFTNESS = 0.4f; //do not change + + public static int argb(int a, int r, int g, int b) { + return ((clamp(a) & 0xFF) << 24) + | ((clamp(r) & 0xFF) << 16) + | ((clamp(g) & 0xFF) << 8) + | (clamp(b) & 0xFF); + } + + public static int alpha(int color, float factor) { + int a = (color >>> 24) & 0xFF; + int na = Math.round(a * Math.max(0f, Math.min(1f, factor))); + return (color & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + public static void rect(float x, float y, float w, float h, int color) { + if (Renderer2D.COLOR == null) return; + int c = applyGuiAlpha(color); + if (((c >>> 24) & 0xFF) <= 0) return; + Renderer2D.COLOR.quad(x, y, w, h, c); + } + + public static void rectQuad(float x, float y, float w, float h, int cTl, int cTr, int cBr, int cBl) { + if (Renderer2D.COLOR == null) return; + int tl = applyGuiAlpha(cTl); + int tr = applyGuiAlpha(cTr); + int br = applyGuiAlpha(cBr); + int bl = applyGuiAlpha(cBl); + if (((tl >>> 24) & 0xFF) <= 0 + && ((tr >>> 24) & 0xFF) <= 0 + && ((br >>> 24) & 0xFF) <= 0 + && ((bl >>> 24) & 0xFF) <= 0) return; + Renderer2D.COLOR.quad(x, y, w, h, tl, tr, br, bl); + } + + public static void rounded(float x, float y, float w, float h, float radius, int color) { + roundedQuad(x, y, w, h, radius, color, color, color, color); + } + + public static void roundedQuad(float x, float y, float w, float h, float radius, int cTl, int cTr, int cBr, int cBl) { + if (Renderer2D.COLOR == null) return; + int tl = applyGuiAlpha(cTl); + int tr = applyGuiAlpha(cTr); + int br = applyGuiAlpha(cBr); + int bl = applyGuiAlpha(cBl); + if (((tl >>> 24) & 0xFF) <= 0 + && ((tr >>> 24) & 0xFF) <= 0 + && ((br >>> 24) & 0xFF) <= 0 + && ((bl >>> 24) & 0xFF) <= 0) return; + Renderer2D.COLOR.roundedRectGradientQuad(x, y, w, h, radius, SOFTNESS, tl, tr, br, bl); + } + + public static void roundedStroke(float x, float y, float w, float h, float radius, float thickness, int color) { + roundedStrokeQuad(x, y, w, h, radius, thickness, color, color, color, color); + } + + public static void roundedStrokeQuad(float x, + float y, + float w, + float h, + float radius, + float thickness, + int cTl, + int cTr, + int cBr, + int cBl) { + if (Renderer2D.COLOR == null) return; + int tl = applyGuiAlpha(cTl); + int tr = applyGuiAlpha(cTr); + int br = applyGuiAlpha(cBr); + int bl = applyGuiAlpha(cBl); + if (((tl >>> 24) & 0xFF) <= 0 + && ((tr >>> 24) & 0xFF) <= 0 + && ((br >>> 24) & 0xFF) <= 0 + && ((bl >>> 24) & 0xFF) <= 0) return; + Renderer2D.COLOR.roundedRectStrokeGradientQuad(x, y, w, h, radius, SOFTNESS, thickness, tl, tr, br, bl); + } + + public static void roundedSoftShadow(float x, + float y, + float w, + float h, + float radius, + float blur, + float innerAlpha, + int color) { + if (Renderer2D.COLOR == null) return; + int c = applyGuiAlpha(color); + if (((c >>> 24) & 0xFF) <= 0) return; + Renderer2D.COLOR.roundedRectSoftShadow(x, y, w, h, radius, blur, innerAlpha, c); + } + + private static int applyGuiAlpha(int color) { + float factor = Math.max(0f, Math.min(1f, ClickGuiRenderer.getAnimAlpha())); + int a = (color >>> 24) & 0xFF; + int na = Math.round(a * factor); + return (color & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + private static int clamp(int c) { + if (c < 0) return 0; + return Math.min(c, 255); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/ClickGuiPickerState.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/ClickGuiPickerState.java new file mode 100644 index 0000000..ca8579a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/ClickGuiPickerState.java @@ -0,0 +1,963 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.picker; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.render.engine.text.*; +import net.minecraft.util.Util; +import net.minecraft.world.item.ItemStack; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D; +import dev.loki.lovisual.features.gui.clickgui.settings.PickerDetailOwner; +import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; + +import java.util.*; + +public final class ClickGuiPickerState { + private static final float SCALE = 2f; + + private static final float WINDOW_W = 340f * SCALE; + private static final float WINDOW_H = 260f * SCALE; + private static final float PADDING = 8f * SCALE; + private static final float HEADER_H = 28f * SCALE; + + private static final float SEARCH_H = 15f * SCALE; + private static final float FILTER_H = SEARCH_H; + private static final float SEARCH_W = 98f * SCALE; + private static final float FILTER_W = 80f * SCALE; + private static final float CLOSE_SIZE = 12f * SCALE; + + private static final float CARD_SIZE = 38f * SCALE; + private static final float CARD_GAP = 5f * SCALE; + private static final float SCROLL_STEP = 15f * SCALE; + private static final float MARQUEE_SPEED = 18f * SCALE; + private static final float MARQUEE_GAP = 10f * SCALE; + private static final float MARQUEE_PAUSE_SEC = 0.55f; + private static final Comparator ENTRY_ORDER = Comparator + .comparing(PickerEntryData::label, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PickerEntryData::id, String.CASE_INSENSITIVE_ORDER); + private final TextListSetting owner; + private final TextListSetting.PickerMode mode; + private final PickerCatalog catalog; + private final String title; + private final StringBuilder search = new StringBuilder(); + private final List allEntries = new ArrayList<>(); + private final List visibleAll = new ArrayList<>(); + private final List visibleSelected = new ArrayList<>(); + private final Map byId = new LinkedHashMap<>(); + private final List cardHits = new ArrayList<>(); + private boolean showAll = true; + private boolean listening; + private float modeAnim; + private float scroll; + private float smoothScroll; + private boolean draggingScrollbar; + private boolean scrollbarVisible; + private float sbTrackX; + private float sbTrackY; + private float sbTrackW; + private float sbTrackH; + private float sbThumbH; + private float contentHeight; + private float windowX; + private float windowY; + private float searchX; + private float searchY; + private float filterX; + private float filterY; + private float closeX; + private float closeY; + private float gridX; + private float gridY; + private float gridW; + private float gridH; + private float detailX; + private float detailY; + private float detailW; + private float detailH; + private String focusedId; + private TextRenderer interBold; + private String hoveredCardId; + private long hoveredCardStartMs; + private float allFilterHoverAnim; + private float selectedFilterHoverAnim; + + public ClickGuiPickerState(TextListSetting owner, String title, TextListSetting.PickerMode mode) { + this.owner = owner; + this.mode = mode == null ? TextListSetting.PickerMode.TEXT : mode; + this.catalog = PickerCatalogFactory.forMode(this.mode); + this.title = title == null || title.isBlank() ? "Select" : title; + reloadEntries(); + refreshFiltered(true); + } + + private static boolean inside(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static float clamp(float v, float min, float max) { + if (v < min) return min; + return Math.min(v, max); + } + + public boolean isListening() { + return listening; + } + + public void toggleListening() { + listening = !listening; + } + + public void stopListening() { + listening = false; + } + + public void insertChar(char c) { + if (Character.isISOControl(c)) return; + search.append(c); + refreshFiltered(true); + } + + public void backspace() { + if (search.length() <= 0) return; + search.deleteCharAt(search.length() - 1); + refreshFiltered(true); + } + + public void handleMouseMove(float mx, float my) { + if (!draggingScrollbar) return; + layout(ClickGuiRenderer.framebufferWidth(), ClickGuiRenderer.framebufferHeight()); + scrollToPosition(my); + } + + public void handleMouseDown(float mx, float my, int button) { + if (button != 0) return; + + layout(ClickGuiRenderer.framebufferWidth(), ClickGuiRenderer.framebufferHeight()); + + if (!inside(mx, my, windowX, windowY, windowW(), WINDOW_H)) { + ClickGuiRenderer.closePickerScreen(); + return; + } + + if (inside(mx, my, closeX, closeY, CLOSE_SIZE, CLOSE_SIZE)) { + ClickGuiRenderer.closePickerScreen(); + return; + } + + if (inside(mx, my, searchX, searchY, SEARCH_W, SEARCH_H)) { + listening = true; + return; + } + listening = false; + + if (inside(mx, my, filterX, filterY, FILTER_W, FILTER_H)) { + float half = FILTER_W * 0.5f; + boolean nextShowAll = mx < filterX + half; + if (showAll != nextShowAll) { + showAll = nextShowAll; + scroll = 0f; + smoothScroll = 0f; + draggingScrollbar = false; + clampScroll(); + } + return; + } + + PickerDetailOwner detailOwner = detailOwner(); + if (detailOwner != null && inside(mx, my, detailX, detailY, detailW, detailH)) { + if (detailOwner.mouseClickedPickerDetails(mx, my, button)) return; + } + + if (handleScrollbarMouseDown(mx, my)) return; + + for (CardHit hit : cardHits) { + if (!inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) continue; + focusedId = hit.getId(); + if (detailOwner != null) { + detailOwner.onPickerFocusChanged(hit.getId()); + if (!detailOwner.shouldToggleSelectionOnCardClick()) { + return; + } + } + toggleSelection(hit.getId()); + return; + } + } + + public void handleMouseUp(float mx, float my, int button) { + PickerDetailOwner detailOwner = detailOwner(); + if (detailOwner != null) { + detailOwner.mouseReleasedPickerDetails(mx, my, button); + } + if (button == 0) { + draggingScrollbar = false; + } + } + + public void scroll(double delta) { + layout(ClickGuiRenderer.framebufferWidth(), ClickGuiRenderer.framebufferHeight()); + + PickerDetailOwner detailOwner = detailOwner(); + if (detailOwner != null) { + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + if (inside(mx, my, detailX, detailY, detailW, detailH) + && detailOwner.mouseScrolledPickerDetails(mx, my, delta)) { + return; + } + } + + scroll += (float) (delta * SCROLL_STEP); + clampScroll(); + } + + public void render(int fbw, int fbh) { + layout(fbw, fbh); + clampScroll(); + + float targetMode = showAll ? 0f : 1f; + modeAnim = AnimationUtility.approach(modeAnim, targetMode, 0.22f); + modeAnim = AnimationUtility.snap(modeAnim, targetMode, 0.01f); + smoothScroll = AnimationUtility.approach(smoothScroll, scroll, 0.2f); + smoothScroll = AnimationUtility.snap(smoothScroll, scroll, 0.05f); + + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + + SettingsGuiPalette palette = SettingsGuiPalette.current(); + Themes.Theme theme = Theme.theme(); + + drawWindow(palette); + drawHeader(palette); + drawCloseButton(mx, my, palette, theme); + drawFilterPill(mx, my, palette, theme); + drawSearchField(mx, my, palette, theme); + drawGrid(mx, my, palette, theme); + drawPickerDetails(mx, my); + drawScrollbar(mx, my, palette); + } + + private void drawWindow(SettingsGuiPalette palette) { + ClickGuiRenderer.drawBlur(windowX, windowY, windowW(), WINDOW_H, 8f * SCALE, 0xFF000000, 200f / 255f); + LayoutRender2D.roundedSoftShadow( + windowX, + windowY, + windowW(), + WINDOW_H, + 8f * SCALE, + 8f * SCALE, + 0.018f, + palette.menuShadow() + ); + LayoutRender2D.roundedQuad( + windowX, + windowY, + windowW(), + WINDOW_H, + 8f * SCALE, + palette.menuWindowBgLeft(), + palette.menuWindowBgRight(), + palette.menuWindowBgRight(), + palette.menuWindowBgLeft() + ); + LayoutRender2D.roundedStroke( + windowX, + windowY, + windowW(), + WINDOW_H, + 8f * SCALE, + 0.1f * SCALE, + palette.menuWindowStroke() + ); + } + + private void drawHeader(SettingsGuiPalette palette) { + float sepY = windowY + HEADER_H; + LayoutRender2D.rectQuad( + windowX + PADDING, + sepY, + windowW() - PADDING * 2f, + 0.5f * SCALE, + palette.menuLineStrong(), + palette.menuLineMid(), + palette.menuLineMid(), + palette.menuLineStrong() + ); + + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + title, + windowX + PADDING, + windowY + 7f * SCALE, + 11f * SCALE, + palette.menuHeaderText(), + false + ); + + String count = owner.getValueSet().size() + " selected"; + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + count, + windowX + PADDING, + windowY + 15.5f * SCALE, + 8f * SCALE, + palette.panelMuted(), + false + ); + } + + private void drawCloseButton(float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + boolean hover = inside(mx, my, closeX, closeY, CLOSE_SIZE, CLOSE_SIZE); + int base = hover ? palette.panelPillActive() : palette.panelPillBase(); + LayoutRender2D.rounded( + closeX, + closeY, + CLOSE_SIZE, + CLOSE_SIZE, + 3.5f * SCALE, + base + ); + LayoutRender2D.roundedStroke( + closeX, + closeY, + CLOSE_SIZE, + CLOSE_SIZE, + 3.5f * SCALE, + 0.5f * SCALE, + palette.panelStroke() + ); + + float pad = 3f * SCALE; + float x1 = closeX + pad; + float y1 = closeY + pad; + float x2 = closeX + CLOSE_SIZE - pad; + float y2 = closeY + CLOSE_SIZE - pad; + int c = hover ? theme.accent() : palette.panelText(); + ClickGuiRenderer.drawLine(x1, y1, x2, y2, c); + ClickGuiRenderer.drawLine(x2, y1, x1, y2, c); + } + + private void drawFilterPill(float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + boolean hover = inside(mx, my, filterX, filterY, FILTER_W, FILTER_H); + boolean allHover = hover && mx < filterX + FILTER_W * 0.5f; + boolean selectedHover = hover && !allHover; + float dt = AnimationUtility.deltaTime(); + allFilterHoverAnim = AnimationUtility.snap( + AnimationUtility.approach(allFilterHoverAnim, allHover ? 1f : 0f, dt, 12f), + allHover ? 1f : 0f, + 0.01f + ); + selectedFilterHoverAnim = AnimationUtility.snap( + AnimationUtility.approach(selectedFilterHoverAnim, selectedHover ? 1f : 0f, dt, 12f), + selectedHover ? 1f : 0f, + 0.01f + ); + + int baseLeft = SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgLeft(), 0.34f); + int baseRight = SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelBgRight(), 0.46f); + if (hover) { + float hoverAnim = Math.max(allFilterHoverAnim, selectedFilterHoverAnim); + baseLeft = SettingsGuiPalette.mix(baseLeft, palette.menuCategoryHoverLeft(), 0.18f * hoverAnim); + baseRight = SettingsGuiPalette.mix(baseRight, palette.menuCategoryHoverRight(), 0.18f * hoverAnim); + } + + ClickGuiRenderer.drawBlur(filterX, filterY, FILTER_W, FILTER_H, 4f * SCALE, palette.panelBlurTint(), 150f / 255f); + LayoutRender2D.roundedQuad( + filterX, + filterY, + FILTER_W, + FILTER_H, + 3.5f * SCALE, + baseLeft, + baseRight, + baseRight, + baseLeft + ); + LayoutRender2D.roundedStrokeQuad( + filterX, + filterY, + FILTER_W, + FILTER_H, + 3.5f * SCALE, + 0.5f * SCALE, + SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerStart(), 0.20f), + SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerEnd(), 0.20f), + SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerEnd(), 0.20f), + SettingsGuiPalette.mix(palette.panelStroke(), palette.moduleDividerStart(), 0.20f) + ); + + float pad = 1.2f * SCALE; + float half = (FILTER_W - pad * 2f) * 0.5f; + float activeX = filterX + pad + half * modeAnim; + float activeHover = showAll ? allFilterHoverAnim : selectedFilterHoverAnim; + int activeA = SettingsGuiPalette.mix(palette.panelPillActive(), SettingsGuiPalette.withAlpha(theme.accentSoft(), 230), 0.18f + activeHover * 0.10f); + int activeB = SettingsGuiPalette.mix(palette.panelPillActive(), SettingsGuiPalette.withAlpha(theme.accent(), 235), 0.12f + activeHover * 0.14f); + int activeC = SettingsGuiPalette.mix(SettingsGuiPalette.darken(palette.panelPillActive(), 0.18f), SettingsGuiPalette.withAlpha(theme.accentSoft(), 215), 0.14f + activeHover * 0.10f); + LayoutRender2D.roundedQuad( + activeX, + filterY + pad, + half, + FILTER_H - pad * 2f, + 3f * SCALE, + activeA, + activeB, + activeC, + activeA + ); + LayoutRender2D.roundedStrokeQuad( + activeX, + filterY + pad, + half, + FILTER_H - pad * 2f, + 3f * SCALE, + 0.35f * SCALE, + SettingsGuiPalette.withAlpha(theme.accentSoft(), 155), + SettingsGuiPalette.withAlpha(theme.accent(), 165), + SettingsGuiPalette.withAlpha(theme.accentSoft(), 135), + SettingsGuiPalette.withAlpha(theme.accentSoft(), 155) + ); + + drawFilterDivider(filterX + pad + half, palette, theme); + + TextRenderer font = ClickGuiRenderer.getInterRegular(); + float textSize = 7.5f * SCALE; + float allW = ClickGuiRenderer.textWidth(font, "All", textSize); + float allH = ClickGuiRenderer.textHeight(font, textSize); + float selW = ClickGuiRenderer.textWidth(font, "Selected", textSize); + float selH = ClickGuiRenderer.textHeight(font, textSize); + + float allCx = filterX + half * 0.5f + pad; + float selCx = filterX + half + half * 0.5f + pad; + float cy = filterY + (FILTER_H - allH) * 0.5f; + + int allColor = showAll + ? palette.panelText() + : SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), 0.22f * allFilterHoverAnim); + int selColor = !showAll + ? palette.panelText() + : SettingsGuiPalette.mix(palette.panelMuted(), palette.panelText(), 0.22f * selectedFilterHoverAnim); + ClickGuiRenderer.drawText(font, "All", allCx - allW * 0.5f, cy, textSize, allColor, false); + ClickGuiRenderer.drawText(font, "Selected", selCx - selW * 0.5f, filterY + (FILTER_H - selH) * 0.5f, textSize, selColor, false); + } + + private void drawFilterDivider(float x, SettingsGuiPalette palette, Themes.Theme theme) { + float backingW = Math.max(1.25f * SCALE, 1.05f); + float backingH = FILTER_H - 4.4f * SCALE; + float backingY = filterY + (FILTER_H - backingH) * 0.5f; + int backingTop = SettingsGuiPalette.withAlpha(palette.panelBgLeft(), 104); + int backingBottom = SettingsGuiPalette.withAlpha(palette.panelBgRight(), 84); + LayoutRender2D.roundedQuad( + x - backingW * 0.5f, + backingY, + backingW, + backingH, + backingW * 0.5f, + backingTop, + backingTop, + backingBottom, + backingBottom + ); + + float lineW = Math.max(0.5f * SCALE, 0.75f); + float lineH = FILTER_H - 6.0f * SCALE; + float lineY = filterY + (FILTER_H - lineH) * 0.5f; + int top = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerStart(), theme.accentSoft(), 0.30f), 154); + int mid = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), theme.accent(), 0.34f), 214); + int bottom = SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(palette.moduleDividerEnd(), palette.panelText(), 0.22f), 142); + LayoutRender2D.roundedQuad( + x - lineW * 0.5f, + lineY, + lineW, + lineH, + lineW * 0.5f, + top, + mid, + bottom, + top + ); + + } + + private void drawSearchField(float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + boolean hover = inside(mx, my, searchX, searchY, SEARCH_W, SEARCH_H); + boolean active = listening; + + int bgL = palette.panelBgLeft(); + int bgR = palette.panelBgRight(); + if (hover) { + bgL = ClickGuiRenderer.mixColor(bgL, palette.panelPillActive(), 0.18f); + bgR = ClickGuiRenderer.mixColor(bgR, palette.panelPillActive(), 0.18f); + } + if (active) { + bgL = ClickGuiRenderer.mixColor(bgL, theme.accentSoft(), 0.16f); + bgR = ClickGuiRenderer.mixColor(bgR, theme.accentSoft(), 0.16f); + } + + ClickGuiRenderer.drawBlur(searchX, searchY, SEARCH_W, SEARCH_H, 3f * SCALE, palette.panelBlurTint(), 170f / 255f); + LayoutRender2D.roundedQuad( + searchX, + searchY, + SEARCH_W, + SEARCH_H, + 3f * SCALE, + bgL, + bgR, + bgR, + bgL + ); + LayoutRender2D.roundedStroke( + searchX, + searchY, + SEARCH_W, + SEARCH_H, + 3f * SCALE, + 0.5f * SCALE, + palette.panelStroke() + ); + + float dividerX = searchX + SEARCH_W - 13f * SCALE; + LayoutRender2D.rect( + dividerX, + searchY + 3f * SCALE, + 0.5f * SCALE, + SEARCH_H - 6f * SCALE, + SettingsGuiPalette.withAlpha(palette.panelMuted(), 95) + ); + + TextRenderer font = ClickGuiRenderer.getInterRegular(); + float textSize = 7.5f * SCALE; + boolean empty = search.length() == 0; + boolean caret = active && AnimationUtility.blink(500L); + String shown = empty && !active ? "Search" : search.toString(); + if (active && caret) shown += "|"; + int color = empty && !active ? palette.panelMuted() : palette.panelText(); + float textY = searchY + (SEARCH_H - ClickGuiRenderer.textHeight(font, textSize)) * 0.5f; + float textX = searchX + 4f * SCALE; + boolean clipped = ScissorFunction.pushRaw(textX, searchY + SCALE, Math.max(1f, dividerX - textX - 1.5f * SCALE), SEARCH_H - 2f * SCALE); + ClickGuiRenderer.drawText(font, shown, textX, textY, textSize, color, false); + if (clipped) ScissorFunction.pop(); + + TextRenderer icons = Fonts.renderer("Icons", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + String icon = "s"; + float iconSize = 8f * SCALE; + float iconW = ClickGuiRenderer.textWidth(icons, icon, iconSize); + float iconH = ClickGuiRenderer.textHeight(icons, iconSize); + float iconX = dividerX + ((searchX + SEARCH_W) - dividerX - iconW) * 0.5f; + float iconY = searchY + (SEARCH_H - iconH) * 0.5f - 0.35f * SCALE; + int iconColor = active ? palette.panelText() : palette.panelMuted(); + ClickGuiRenderer.drawText(icons, icon, iconX, iconY, iconSize, iconColor, false); + } + + private void drawGrid(float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + List visible = visibleEntries(); + Set selected = owner.getValueSet(); + int columns = columns(); + + cardHits.clear(); + ClickGuiRenderer.beginPickerIconScissor(gridX, gridY, gridW, gridH); + boolean clipped = ScissorFunction.pushRaw(gridX, gridY, gridW, gridH); + + float step = CARD_SIZE + CARD_GAP; + float iconScale = 1.55f * SCALE; + float iconPx = 16f * iconScale; + TextRenderer font = ClickGuiRenderer.getInterRegular(); + float nameSize = 5.9f * SCALE; + boolean hasHoveredCard = false; + + for (int i = 0; i < visible.size(); i++) { + int col = i % columns; + int row = i / columns; + + float cardX = gridX + col * step; + float cardY = gridY + row * step + smoothScroll; + if (cardY + CARD_SIZE < gridY - 0.5f * SCALE) continue; + if (cardY > gridY + gridH + 0.5f * SCALE) continue; + + PickerEntryData entry = visible.get(i); + boolean isSelected = selected.contains(entry.getId()); + boolean hover = inside(mx, my, cardX, cardY, CARD_SIZE, CARD_SIZE); + if (hover) { + hasHoveredCard = true; + if (!entry.getId().equals(hoveredCardId)) { + hoveredCardId = entry.getId(); + hoveredCardStartMs = Util.getMillis(); + } + } + + int left = SettingsGuiPalette.withAlpha( + SettingsGuiPalette.mix(theme.windowBg(), theme.surface(), 0.42f), + 205 + ); + int right = SettingsGuiPalette.withAlpha( + SettingsGuiPalette.darken(SettingsGuiPalette.mix(theme.windowHeader(), theme.windowBg(), 0.55f), 0.26f), + 205 + ); + if (isSelected) { + left = ClickGuiRenderer.mixColor(left, theme.accentSoft(), 0.14f); + right = ClickGuiRenderer.mixColor(right, theme.accentSoft(), 0.26f); + } + if (hover) { + left = ClickGuiRenderer.mixColor(left, palette.moduleCardTopStrong(), 0.40f); + right = ClickGuiRenderer.mixColor(right, palette.moduleCardBottomStrong(), 0.42f); + } + + LayoutRender2D.roundedQuad( + cardX, + cardY, + CARD_SIZE, + CARD_SIZE, + 4f * SCALE, + left, + right, + right, + left + ); + + int stroke = isSelected ? theme.accentSoft() : palette.moduleDividerStart(); + float strokeW = isSelected ? 0.8f * SCALE : 0.45f * SCALE; + LayoutRender2D.roundedStroke(cardX, cardY, CARD_SIZE, CARD_SIZE, 4f * SCALE, strokeW, stroke); + + float lineY = cardY + CARD_SIZE - 11f * SCALE; + LayoutRender2D.rectQuad( + cardX + 3f * SCALE, + lineY, + CARD_SIZE - 6f * SCALE, + 0.45f * SCALE, + palette.moduleDividerStart(), + palette.moduleDividerEnd(), + palette.moduleDividerEnd(), + palette.moduleDividerStart() + ); + + ItemStack stack = entry.stack(); + if (stack != null && !stack.isEmpty()) { + float iconX = cardX + (CARD_SIZE - iconPx) * 0.5f; + float iconY = cardY + 4f * SCALE; + float clipX = cardX + SCALE; + float clipY = cardY + SCALE; + float clipW = CARD_SIZE - 2f * SCALE; + float clipH = Math.max(1f, lineY - clipY - 0.75f * SCALE); + ClickGuiRenderer.queuePickerIcon( + stack, + iconX, + iconY, + iconScale, + clipX, + clipY, + clipW, + clipH + ); + } else { + TextRenderer bold = interBold(); + float qSize = 13f * SCALE; + String q = "?"; + float qW = ClickGuiRenderer.textWidth(bold, q, qSize); + float qH = ClickGuiRenderer.textHeight(bold, qSize); + float iconAreaTop = cardY + 3.5f * SCALE; + float iconAreaBottom = lineY - SCALE; + float qX = cardX + (CARD_SIZE - qW) * 0.5f; + float qY = iconAreaTop + ((iconAreaBottom - iconAreaTop) - qH) * 0.5f; + int qColor = isSelected + ? SettingsGuiPalette.withAlpha(palette.moduleTitleText(), 210) + : SettingsGuiPalette.withAlpha(palette.panelMuted(), 190); + ClickGuiRenderer.drawText(bold, q, qX, qY, qSize, qColor, false); + } + + float textMaxW = CARD_SIZE - 6f * SCALE; + float textY = lineY + 1.5f * SCALE; + int textColor = isSelected ? palette.moduleTitleText() : palette.moduleDescriptionText(); + drawCardLabel(font, entry.label(), cardX, textY, textMaxW, nameSize, textColor, hover); + + cardHits.add(new CardHit(entry.getId(), cardX, cardY, CARD_SIZE, CARD_SIZE)); + } + if (!hasHoveredCard) { + hoveredCardId = null; + hoveredCardStartMs = 0L; + } + + if (clipped) { + ScissorFunction.pop(); + } + ClickGuiRenderer.endPickerIconScissor(); + + if (visible.isEmpty()) { + ClickGuiRenderer.drawText( + ClickGuiRenderer.getInterRegular(), + showAll ? "No entries" : "Nothing selected", + gridX + 4f * SCALE, + gridY + 4f * SCALE, + 7.2f * SCALE, + palette.panelMuted(), + false + ); + } + } + + private void drawScrollbar(float mx, float my, SettingsGuiPalette palette) { + if (!scrollbarVisible || contentHeight <= gridH + 0.5f) { + scrollbarVisible = false; + return; + } + + float baseW = 2.25f * SCALE; + boolean hover = isScrollbarHovered(mx, my) || draggingScrollbar; + if (hover) { + SystemCursor.set(SystemCursor.CursorType.SCROLL); + } + float trackW = hover ? 3.2f * SCALE : baseW; + float trackX = gridX + gridW - trackW - SCALE; + float trackY = gridY + SCALE; + float trackH = Math.max(12f * SCALE, gridH - 2f * SCALE); + + sbTrackX = trackX; + sbTrackY = trackY; + sbTrackW = trackW; + sbTrackH = trackH; + + LayoutRender2D.roundedQuad( + trackX, + trackY, + trackW, + trackH, + 2f * SCALE, + palette.moduleScrollTrackA(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackB(), + palette.moduleScrollTrackA() + ); + + float thumbH = Math.max(16f * SCALE, trackH * (gridH / contentHeight)); + sbThumbH = thumbH; + float ratio = contentHeight <= gridH ? 0f : (-smoothScroll / (contentHeight - gridH)); + float thumbY = trackY + (trackH - thumbH) * clamp(ratio, 0f, 1f); + + LayoutRender2D.roundedQuad( + trackX, + thumbY, + trackW, + thumbH, + 2f * SCALE, + palette.moduleScrollHandleA(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleB(), + palette.moduleScrollHandleA() + ); + } + + private void drawCardLabel(TextRenderer font, String label, float cardX, float textY, float maxW, float size, int color, boolean hover) { + String text = label == null ? "" : label; + float fullW = ClickGuiRenderer.textWidth(font, text, size); + if (!hover || fullW <= maxW + 0.5f * SCALE) { + String shown = ClickGuiRenderer.fitText(font, text, size, maxW); + float shownW = ClickGuiRenderer.textWidth(font, shown, size); + ClickGuiRenderer.drawText(font, shown, cardX + (CARD_SIZE - shownW) * 0.5f, textY, size, color, false); + return; + } + + float clipX = cardX + (CARD_SIZE - maxW) * 0.5f; + float textH = Math.max(1f, ClickGuiRenderer.textHeight(font, size)); + boolean clipped = ScissorFunction.pushRaw(clipX, textY - SCALE, maxW, textH + 2f * SCALE); + float elapsed = Math.max(0f, (Util.getMillis() - hoveredCardStartMs) / 1000f); + float cycleDistance = fullW + MARQUEE_GAP; + float cycleTime = MARQUEE_PAUSE_SEC + cycleDistance / Math.max(1f, MARQUEE_SPEED); + float t = elapsed % cycleTime; + float offset = t <= MARQUEE_PAUSE_SEC ? 0f : -(t - MARQUEE_PAUSE_SEC) * MARQUEE_SPEED; + + ClickGuiRenderer.drawText(font, text, clipX + offset, textY, size, color, false); + if (cycleDistance > maxW * 0.5f) { + ClickGuiRenderer.drawText(font, text, clipX + offset + cycleDistance, textY, size, color, false); + } + if (clipped) ScissorFunction.pop(); + } + + private float windowW() { + PickerDetailOwner detailOwner = detailOwner(); + return WINDOW_W + (detailOwner != null ? detailOwner.pickerDetailWidth() : 0f); + } + + private PickerDetailOwner detailOwner() { + return owner instanceof PickerDetailOwner detailOwner ? detailOwner : null; + } + + private void drawPickerDetails(float mx, float my) { + PickerDetailOwner detailOwner = detailOwner(); + if (detailOwner == null) return; + detailOwner.renderPickerDetails(detailX, detailY, detailW, detailH, focusedId, mx, my); + } + + private void layout(int fbw, int fbh) { + windowX = (fbw - windowW()) * 0.5f; + windowY = (fbh - WINDOW_H) * 0.5f; + + closeX = windowX + windowW() - PADDING - CLOSE_SIZE; + closeY = windowY + 7f * SCALE; + + searchX = closeX - 3f * SCALE - SEARCH_W; + searchY = windowY + 6f * SCALE; + + filterX = searchX - 3f * SCALE - FILTER_W; + filterY = searchY; + + gridX = windowX + PADDING; + gridY = windowY + HEADER_H + 6f * SCALE; + PickerDetailOwner detailOwner = detailOwner(); + detailW = detailOwner != null ? detailOwner.pickerDetailWidth() : 0f; + detailY = gridY; + detailH = WINDOW_H - (gridY - windowY) - PADDING; + detailX = windowX + windowW() - PADDING - detailW; + gridW = windowW() - PADDING * 2f - (detailOwner != null ? detailW + CARD_GAP : 0f); + gridH = detailH; + } + + private void reloadEntries() { + allEntries.clear(); + allEntries.addAll(catalog.entries(owner)); + } + + private void refreshFiltered(boolean resetScroll) { + String needle = search.toString().trim().toLowerCase(Locale.ROOT); + + byId.clear(); + for (PickerEntryData entry : allEntries) { + byId.put(entry.getId(), entry); + } + + visibleAll.clear(); + for (PickerEntryData entry : allEntries) { + if (!matches(entry, needle)) continue; + visibleAll.add(entry); + } + visibleAll.sort(ENTRY_ORDER); + + visibleSelected.clear(); + for (String id : owner.getValueSet()) { + PickerEntryData entry = byId.get(id); + if (entry == null) { + entry = new PickerEntryData(id, id, ItemStack.EMPTY); + } + if (!matches(entry, needle)) continue; + visibleSelected.add(entry); + } + visibleSelected.sort(ENTRY_ORDER); + + if (resetScroll) { + scroll = 0f; + smoothScroll = 0f; + draggingScrollbar = false; + } else { + clampScroll(); + } + } + + private List visibleEntries() { + return showAll ? visibleAll : visibleSelected; + } + + private void toggleSelection(String id) { + Set next = owner.getValueSet(); + if (next.contains(id)) { + next.remove(id); + } else { + next.add(owner.normalizeEntry(id)); + } + owner.setValueSet(next); + if (!next.contains(focusedId)) { + focusedId = next.isEmpty() ? null : next.iterator().next(); + PickerDetailOwner detailOwner = detailOwner(); + if (detailOwner != null && focusedId != null) detailOwner.onPickerFocusChanged(focusedId); + } + refreshFiltered(false); + } + + private boolean matches(PickerEntryData entry, String needle) { + if (needle == null || needle.isBlank()) return true; + String id = entry.getId() == null ? "" : entry.getId().toLowerCase(Locale.ROOT); + String label = entry.label() == null ? "" : entry.label().toLowerCase(Locale.ROOT); + return id.contains(needle) || label.contains(needle); + } + + private int columns() { + return Math.max(1, (int) Math.floor((gridW + CARD_GAP) / (CARD_SIZE + CARD_GAP))); + } + + private float computeContentHeight(int entriesCount) { + if (entriesCount <= 0) return 0f; + int cols = columns(); + int rows = (entriesCount + cols - 1) / cols; + return rows * CARD_SIZE + Math.max(0, rows - 1) * CARD_GAP; + } + + private void clampScroll() { + contentHeight = computeContentHeight(visibleEntries().size()); + float minScroll = Math.min(0f, gridH - contentHeight); + scroll = clamp(scroll, minScroll, 0f); + smoothScroll = clamp(smoothScroll, minScroll, 0f); + scrollbarVisible = contentHeight > gridH + 0.5f; + } + + private boolean handleScrollbarMouseDown(float mx, float my) { + if (!scrollbarVisible) return false; + if (!isScrollbarHovered(mx, my)) return false; + draggingScrollbar = true; + scrollToPosition(my); + return true; + } + + private boolean isScrollbarHovered(float mx, float my) { + if (!scrollbarVisible) return false; + float pad = 3f * SCALE; + return mx >= sbTrackX - pad && + mx <= sbTrackX + sbTrackW + pad && + my >= sbTrackY - pad && + my <= sbTrackY + sbTrackH + pad; + } + + private void scrollToPosition(float mouseY) { + if (contentHeight <= gridH + 0.5f) { + scroll = 0f; + smoothScroll = 0f; + return; + } + float trackSpan = sbTrackH - sbThumbH; + if (trackSpan <= 0f) return; + float rel = mouseY - sbTrackY - sbThumbH * 0.5f; + float t = clamp(rel / trackSpan, 0f, 1f); + float scrollRange = contentHeight - gridH; + scroll = -scrollRange * t; + smoothScroll = scroll; + } + + private TextRenderer interBold() { + if (interBold != null) return interBold; + FontFamily family = Fonts.getFamily("Inter"); + if (family == null) { + interBold = ClickGuiRenderer.getInterRegular(); + return interBold; + } + FontFace face = family.get(FontInfo.Type.Bold); + if (face == null) { + interBold = ClickGuiRenderer.getInterRegular(); + return interBold; + } + interBold = new CustomTextRenderer(face); + return interBold; + } + + private record CardHit(String id, float x, float y, float w, float h) { + public String getId() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalog.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalog.java new file mode 100644 index 0000000..3c554d1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalog.java @@ -0,0 +1,17 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.picker; + +import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting; + +import java.util.List; + +@FunctionalInterface +public interface PickerCatalog { + List entries(TextListSetting owner); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalogFactory.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalogFactory.java new file mode 100644 index 0000000..524f4fa --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerCatalogFactory.java @@ -0,0 +1,196 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.picker; + +import net.minecraft.client.resources.language.I18n; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.equipment.Equippable; +import net.minecraft.world.level.block.Block; +import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting; +import dev.loki.lovisual.util.item.EnchantMeta; +import dev.loki.lovisual.util.item.EnchantRegistry; +import dev.loki.lovisual.util.screen.ScreenCatalog; + +import java.util.*; + +public enum PickerCatalogFactory { + ; + private static final Comparator ENTRY_ORDER = Comparator + .comparing(PickerEntryData::label, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PickerEntryData::id, String.CASE_INSENSITIVE_ORDER); + + public static PickerCatalog forMode(TextListSetting.PickerMode mode) { + if (mode == null) return owner -> List.of(); + return switch (mode) { + case SCREENS -> PickerCatalogFactory::screenEntries; + case BLOCKS -> owner -> blockEntries(); + case ITEMS -> owner -> itemEntries(); + case EQUIPPABLE_ARMOR -> owner -> equippableArmorEntries(); + case ENCHANTMENTS -> owner -> enchantmentEntries(); + case ALL -> owner -> allEntries(); + case SOUNDS -> owner -> soundEntries(); + case LIVING_ENTITIES -> owner -> livingEntityEntries(); + case ENTITIES -> owner -> entityEntries(); + case TEXT -> owner -> List.of(); + }; + } + + private static List screenEntries(TextListSetting owner) { + Set selected = owner == null ? Set.of() : owner.getValueSet(); + List out = new ArrayList<>(); + for (ScreenCatalog.Entry entry : ScreenCatalog.entries(selected)) { + out.add(new PickerEntryData(entry.getId(), entry.label(), ItemStack.EMPTY)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List blockEntries() { + List out = new ArrayList<>(); + for (Block block : BuiltInRegistries.BLOCK) { + String id = BuiltInRegistries.BLOCK.getKey(block).toString(); + String label = I18n.get(block.getDescriptionId()); + ItemStack stack = block.asItem().getDefaultInstance(); + out.add(new PickerEntryData(id, label, stack)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List itemEntries() { + List out = new ArrayList<>(); + for (Item item : BuiltInRegistries.ITEM) { + String id = BuiltInRegistries.ITEM.getKey(item).toString(); + ItemStack stack = item.getDefaultInstance(); + String label = stack.getHoverName().getString(); + out.add(new PickerEntryData(id, label, stack)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List equippableArmorEntries() { + List out = new ArrayList<>(); + for (Item item : BuiltInRegistries.ITEM) { + ItemStack stack = item.getDefaultInstance(); + if (!isArmorEquippable(stack)) continue; + String id = BuiltInRegistries.ITEM.getKey(item).toString(); + String label = stack.getHoverName().getString(); + out.add(new PickerEntryData(id, label, stack)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List enchantmentEntries() { + List out = new ArrayList<>(); + for (EnchantMeta meta : EnchantRegistry.REGISTRY.values()) { + String id = "minecraft:" + meta.key(); + out.add(new PickerEntryData(id, meta.localizedName(), ItemStack.EMPTY)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List allEntries() { + Set seen = new LinkedHashSet<>(); + List out = new ArrayList<>(); + for (Block block : BuiltInRegistries.BLOCK) { + String id = BuiltInRegistries.BLOCK.getKey(block).toString(); + if (!seen.add(id)) continue; + String label = I18n.get(block.getDescriptionId()); + ItemStack stack = block.asItem().getDefaultInstance(); + out.add(new PickerEntryData(id, label, stack)); + } + for (Item item : BuiltInRegistries.ITEM) { + String id = BuiltInRegistries.ITEM.getKey(item).toString(); + if (!seen.add(id)) continue; + ItemStack stack = item.getDefaultInstance(); + String label = stack.getHoverName().getString(); + out.add(new PickerEntryData(id, label, stack)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List soundEntries() { + List out = new ArrayList<>(); + for (SoundEvent sound : BuiltInRegistries.SOUND_EVENT) { + Identifier id = BuiltInRegistries.SOUND_EVENT.getKey(sound); + if (id == null) continue; + out.add(new PickerEntryData(id.toString(), soundLabel(id), ItemStack.EMPTY)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List livingEntityEntries() { + List out = new ArrayList<>(); + for (EntityType type : BuiltInRegistries.ENTITY_TYPE) { + Class baseClass = type.getBaseClass(); + if (baseClass == null || !net.minecraft.world.entity.LivingEntity.class.isAssignableFrom(baseClass)) + continue; + Identifier id = BuiltInRegistries.ENTITY_TYPE.getKey(type); + if (id == null) continue; + String label = I18n.get(type.getDescriptionId()); + out.add(new PickerEntryData(id.toString(), label, ItemStack.EMPTY)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static List entityEntries() { + List out = new ArrayList<>(); + for (EntityType type : BuiltInRegistries.ENTITY_TYPE) { + Identifier id = BuiltInRegistries.ENTITY_TYPE.getKey(type); + if (id == null) continue; + String label = I18n.get(type.getDescriptionId()); + out.add(new PickerEntryData(id.toString(), label, ItemStack.EMPTY)); + } + out.sort(ENTRY_ORDER); + return out; + } + + private static String humanizeSnakeCase(String id) { + if (id == null || id.isBlank()) return ""; + String[] parts = id.toLowerCase(Locale.ROOT).split("_"); + StringBuilder out = new StringBuilder(id.length() + 4); + for (String part : parts) { + if (part.isEmpty()) continue; + if (out.length() > 0) out.append(' '); + out.append(Character.toUpperCase(part.charAt(0))); + if (part.length() > 1) out.append(part, 1, part.length()); + } + return out.toString(); + } + + private static String soundLabel(Identifier id) { + if (id == null) return ""; + String label = humanizeSnakeCase(id.getPath().replace('.', '_')); + if ("minecraft".equals(id.getNamespace())) return label; + return id.getNamespace() + ": " + label; + } + + private static boolean isArmorEquippable(ItemStack stack) { + if (stack == null || stack.isEmpty()) return false; + Equippable eq = stack.get(DataComponents.EQUIPPABLE); + if (eq == null) return false; + EquipmentSlot slot = eq.slot(); + return slot == EquipmentSlot.HEAD + || slot == EquipmentSlot.CHEST + || slot == EquipmentSlot.LEGS + || slot == EquipmentSlot.FEET; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerEntryData.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerEntryData.java new file mode 100644 index 0000000..e458cc6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/picker/PickerEntryData.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.picker; + +import net.minecraft.world.item.ItemStack; + +public record PickerEntryData(String id, String label, ItemStack stack) { + public String getId() { + return id; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/protocol/CombatProtocolHeuristicsEditorState.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/protocol/CombatProtocolHeuristicsEditorState.java new file mode 100644 index 0000000..56c7ecb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/protocol/CombatProtocolHeuristicsEditorState.java @@ -0,0 +1,932 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.protocol; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette; +import dev.loki.lovisual.features.gui.clickgui.settings.ProtocolHeuristicsSetting; +import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.combat.protocol.CombatProtocolHeuristicSource; +import dev.loki.lovisual.util.combat.protocol.CombatProtocolHeuristicsConfig; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.util.text.TextSelection; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public final class CombatProtocolHeuristicsEditorState { + private static final float SCALE = 2f; + private static final float WINDOW_W = 420f * SCALE; + private static final float WINDOW_H = 280f * SCALE; + private static final float PADDING = 9f * SCALE; + private static final float HEADER_H = 31f * SCALE; + private static final float SOURCE_W = 108f * SCALE; + private static final float GAP = 7f * SCALE; + private static final float CLOSE_SIZE = 12f * SCALE; + + private final ProtocolHeuristicsSetting owner; + private final String title; + private final List sourceHits = new ArrayList<>(); + private final EnumMap sourceHover = + new EnumMap<>(CombatProtocolHeuristicSource.class); + private final EnumMap sourceSelect = + new EnumMap<>(CombatProtocolHeuristicSource.class); + private final EnumMap> editors = + new EnumMap<>(CombatProtocolHeuristicSource.class); + + private CombatProtocolHeuristicSource selected = CombatProtocolHeuristicSource.BOSSBAR; + private InlineEditor focusedEditor; + private float windowX; + private float windowY; + private float openAnim; + private Rect close = Rect.EMPTY; + private Rect reset = Rect.EMPTY; + private float closeHover; + private float resetHover; + + public CombatProtocolHeuristicsEditorState(ProtocolHeuristicsSetting owner, String title) { + this.owner = owner; + this.title = title == null || title.isBlank() ? tr("title", "Protocol heuristics") : title; + + CombatProtocolHeuristicsConfig config = CombatProtocolHeuristicsConfig.get(); + for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) { + EnumMap byFamily = + new EnumMap<>(CombatProtocolHeuristicsConfig.ProtocolFamily.class); + for (CombatProtocolHeuristicsConfig.ProtocolFamily family : CombatProtocolHeuristicsConfig.ProtocolFamily.values()) { + byFamily.put(family, new InlineEditor(source, family, config.patterns(source, family))); + } + editors.put(source, byFamily); + sourceHover.put(source, 0f); + sourceSelect.put(source, source == selected ? 1f : 0f); + } + } + + public void handleMouseMove(float mx, float my) { + if (focusedEditor != null) focusedEditor.mouseMoved(mx, my); + } + + public void handleMouseDown(float mx, float my, int button) { + if (button != GLFW.GLFW_MOUSE_BUTTON_LEFT && button != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return; + if (!inside(mx, my, windowX, windowY, WINDOW_W, WINDOW_H) || close.contains(mx, my)) { + ClickGuiRenderer.closeProtocolHeuristicsEditor(); + return; + } + + for (SourceHit hit : sourceHits) { + if (!hit.card().contains(mx, my)) continue; + blurEditor(); + selected = hit.source(); + boolean enabled = sourceEnabled(hit.source()); + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + owner.setSourceEnabled(hit.source().key(), !enabled); + } + return; + } + + InlineEditor legacy = editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY); + InlineEditor modern = editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN); + if (legacy.mousePressed(mx, my, button)) { + focus(legacy); + return; + } + if (modern.mousePressed(mx, my, button)) { + focus(modern); + return; + } + + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT && reset.contains(mx, my)) { + blurEditor(); + CombatProtocolHeuristicsConfig.get().reset(selected); + reloadEditors(selected); + return; + } + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) blurEditor(); + } + + public void handleMouseUp(float mx, float my, int button) { + if (focusedEditor != null) focusedEditor.mouseReleased(button); + } + + public void scroll(double delta) { + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + InlineEditor legacy = editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY); + InlineEditor modern = editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN); + if (legacy.scroll(mx, my, delta)) return; + modern.scroll(mx, my, delta); + } + + public boolean charTyped(char chr) { + return focusedEditor != null && focusedEditor.charTyped(chr); + } + + public boolean keyPressed(int key, int scancode, int action, int modifiers) { + if (action != GLFW.GLFW_PRESS && action != GLFW.GLFW_REPEAT) return focusedEditor != null; + if (focusedEditor != null) { + if (key == GLFW.GLFW_KEY_ESCAPE) { + blurEditor(); + return true; + } + return focusedEditor.keyPressed(key, modifiers); + } + if (key == GLFW.GLFW_KEY_ESCAPE) { + ClickGuiRenderer.closeProtocolHeuristicsEditor(); + return true; + } + return true; + } + + public void render(int width, int height) { + float dt = AnimationUtility.deltaTime(); + openAnim = AnimationUtility.snap(AnimationUtility.approach(openAnim, 1f, dt, 8.5f), 1f, 0.008f); + float eased = easeOutCubic(openAnim); + windowX = (width - WINDOW_W) * 0.5f; + windowY = (height - WINDOW_H) * 0.5f + (1f - eased) * 10f * SCALE; + + SettingsGuiPalette palette = SettingsGuiPalette.current(); + Themes.Theme theme = Theme.theme(); + TextRenderer regular = ClickGuiRenderer.getInterRegular(); + TextRenderer medium = ClickGuiRenderer.getInterMedium(); + float mx = ClickGuiRenderer.getMouseX(); + float my = ClickGuiRenderer.getMouseY(); + + float previousAlpha = ClickGuiRenderer.setRenderAlphaMultiplier(eased); + try { + int shadow = SettingsGuiPalette.withAlpha(palette.menuShadow(), Math.round(255f * eased)); + ClickGuiRenderer.drawBlur(windowX, windowY, WINDOW_W, WINDOW_H, 9f * SCALE, 0xFF000000, 210f / 255f); + ClickGuiRenderer.drawRoundedRectShadow(windowX, windowY, WINDOW_W, WINDOW_H, 9f * SCALE, 13f * SCALE, 1.5f * SCALE, shadow); + ClickGuiRenderer.drawRoundedRectGradient( + windowX, windowY, WINDOW_W, WINDOW_H, 9f * SCALE, + palette.menuWindowBgLeft(), palette.menuWindowBgRight(), 0f + ); + ClickGuiRenderer.drawRoundedRectStrokeGradient( + windowX, windowY, WINDOW_W, WINDOW_H, 9f * SCALE, 0.55f * SCALE, + SettingsGuiPalette.mix(palette.menuWindowStroke(), theme.accentSoft(), 0.18f), + SettingsGuiPalette.mix(palette.menuWindowStroke(), theme.accent(), 0.10f), + 0f + ); + + renderHeader(regular, medium, mx, my, palette, theme); + + float contentY = windowY + HEADER_H + PADDING; + float contentH = WINDOW_H - HEADER_H - PADDING * 2f; + renderSources(windowX + PADDING, contentY, SOURCE_W, contentH, regular, medium, mx, my, palette, theme); + + float detailsX = windowX + PADDING + SOURCE_W + GAP; + float detailsW = WINDOW_W - PADDING * 2f - SOURCE_W - GAP; + renderDetails(detailsX, contentY, detailsW, contentH, regular, medium, mx, my, palette, theme); + } finally { + ClickGuiRenderer.restoreRenderAlphaMultiplier(previousAlpha); + } + } + + private void renderHeader(TextRenderer regular, TextRenderer medium, float mx, float my, + SettingsGuiPalette palette, Themes.Theme theme) { + float accentX = windowX + PADDING; + float accentY = windowY + 7f * SCALE; + ClickGuiRenderer.drawRoundedRect(accentX, accentY, 2f * SCALE, 17f * SCALE, SCALE, theme.accent()); + ClickGuiRenderer.drawRoundedRectGlow(accentX, accentY, 2f * SCALE, 17f * SCALE, SCALE, 7f * SCALE, + SettingsGuiPalette.withAlpha(theme.accent(), 90)); + + ClickGuiRenderer.drawText(medium, title, accentX + 7f * SCALE, windowY + 5.5f * SCALE, + 10.5f * SCALE, palette.menuHeaderText(), false); + String subtitle = tr("subtitle", "Configure protocol signals independently for each source"); + ClickGuiRenderer.drawText(regular, subtitle, accentX + 7f * SCALE, windowY + 17f * SCALE, + 6.3f * SCALE, palette.panelMuted(), false); + + float separatorY = windowY + HEADER_H; + ClickGuiRenderer.drawGradientRect(windowX + PADDING, separatorY, WINDOW_W - PADDING * 2f, + 0.5f * SCALE, palette.menuLineStrong(), palette.menuLineMid(), + palette.menuLineMid(), palette.menuLineStrong()); + + close = new Rect(windowX + WINDOW_W - PADDING - CLOSE_SIZE, windowY + 9f * SCALE, CLOSE_SIZE, CLOSE_SIZE); + closeHover = animate(closeHover, close.contains(mx, my), 14f); + int bg = SettingsGuiPalette.mix(palette.panelPillBase(), palette.panelPillActive(), 0.22f + closeHover * 0.58f); + ClickGuiRenderer.drawRoundedRect(close.x, close.y, close.w, close.h, 3.5f * SCALE, bg); + ClickGuiRenderer.drawRoundedRectStroke(close.x, close.y, close.w, close.h, 3.5f * SCALE, + 0.45f * SCALE, SettingsGuiPalette.mix(palette.panelStroke(), theme.accentSoft(), closeHover * 0.45f)); + float pad = 3.2f * SCALE; + int color = SettingsGuiPalette.mix(palette.panelMuted(), theme.accent(), closeHover); + ClickGuiRenderer.drawLine(close.x + pad, close.y + pad, close.x + close.w - pad, close.y + close.h - pad, color); + ClickGuiRenderer.drawLine(close.x + close.w - pad, close.y + pad, close.x + pad, close.y + close.h - pad, color); + if (close.contains(mx, my)) SystemCursor.set(SystemCursor.CursorType.HAND); + } + + private void renderSources(float x, float y, float w, float h, TextRenderer regular, TextRenderer medium, + float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + sourceHits.clear(); + panel(x, y, w, h, palette); + ClickGuiRenderer.drawText(medium, tr("sources", "Sources"), x + 8f * SCALE, y + 7f * SCALE, + 7.2f * SCALE, palette.panelText(), false); + ClickGuiRenderer.drawText(regular, tr("source_controls", "LMB toggle · RMB configure"), + x + 8f * SCALE, y + 16f * SCALE, 5.3f * SCALE, palette.panelMuted(), false); + + float rowY = y + 28f * SCALE; + float rowH = 39f * SCALE; + float rowGap = 5f * SCALE; + for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) { + Rect card = new Rect(x + 5f * SCALE, rowY, w - 10f * SCALE, rowH); + boolean hovered = card.contains(mx, my); + boolean active = source == selected; + boolean enabled = sourceEnabled(source); + float hover = animate(sourceHover.get(source), hovered, 13f); + float select = animate(sourceSelect.get(source), active, 11f); + sourceHover.put(source, hover); + sourceSelect.put(source, select); + + int baseLeft = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverLeft(), hover * 0.30f); + int baseRight = SettingsGuiPalette.mix(palette.panelPillBase(), palette.menuCategoryHoverRight(), hover * 0.30f); + float accentMix = Math.max(enabled ? 0.42f : 0f, select * 0.32f); + int left = SettingsGuiPalette.mix(baseLeft, theme.accentSoft(), accentMix); + int right = SettingsGuiPalette.mix(baseRight, theme.accent(), accentMix * 0.72f); + ClickGuiRenderer.drawRoundedRectGradient(card.x, card.y, card.w, card.h, 5.5f * SCALE, left, right, 0f); + ClickGuiRenderer.drawRoundedRectStrokeGradient( + card.x, card.y, card.w, card.h, 5.5f * SCALE, 0.5f * SCALE, + SettingsGuiPalette.mix(palette.panelStroke(), theme.accentSoft(), Math.max(select, enabled ? 0.55f : 0f)), + SettingsGuiPalette.mix(palette.panelStroke(), theme.accent(), Math.max(select * 0.7f, enabled ? 0.32f : 0f)), + 0f + ); + + float markerW = (1.5f + 1.5f * select) * SCALE; + int marker = enabled ? theme.accent() : SettingsGuiPalette.withAlpha(palette.panelMuted(), 100); + ClickGuiRenderer.drawRoundedRect(card.x + 3f * SCALE, card.y + 7f * SCALE, markerW, + card.h - 14f * SCALE, markerW * 0.5f, marker); + if (enabled) { + ClickGuiRenderer.drawRoundedRectGlow(card.x + 3f * SCALE, card.y + 7f * SCALE, markerW, + card.h - 14f * SCALE, markerW * 0.5f, 5f * SCALE, + SettingsGuiPalette.withAlpha(theme.accent(), Math.round(55 + 55 * hover))); + } + + String name = sourceName(source); + ClickGuiRenderer.drawText(medium, name, card.x + 9f * SCALE, card.y + 7f * SCALE, + 7f * SCALE, palette.panelText(), false); + String state = enabled ? tr("enabled", "Enabled") : tr("disabled", "Disabled"); + int stateColor = enabled ? theme.accent() : palette.panelMuted(); + ClickGuiRenderer.drawText(regular, state, card.x + 9f * SCALE, card.y + 20f * SCALE, + 5.8f * SCALE, stateColor, false); + + int count = editor(source, CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY).patternCount() + + editor(source, CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN).patternCount(); + String countLabel = tr("rules_short", "%s rules", count); + float countSize = 5.3f * SCALE; + float countW = ClickGuiRenderer.textWidth(regular, countLabel, countSize); + ClickGuiRenderer.drawText(regular, countLabel, card.x + card.w - 7f * SCALE - countW, + card.y + 20f * SCALE, countSize, palette.panelMuted(), false); + + sourceHits.add(new SourceHit(source, card)); + if (hovered) SystemCursor.set(SystemCursor.CursorType.HAND); + rowY += rowH + rowGap; + } + } + + private void renderDetails(float x, float y, float w, float h, TextRenderer regular, TextRenderer medium, + float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + panel(x, y, w, h, palette); + boolean enabled = sourceEnabled(selected); + ClickGuiRenderer.drawText(medium, sourceName(selected), x + 8f * SCALE, y + 6f * SCALE, + 8.2f * SCALE, palette.panelText(), false); + String sourceHint = tr("source_hint." + selected.key(), defaultSourceHint(selected)); + ClickGuiRenderer.drawText(regular, sourceHint, x + 8f * SCALE, y + 17f * SCALE, + 5.8f * SCALE, palette.panelMuted(), false); + + String status = enabled ? tr("enabled", "Enabled") : tr("disabled", "Disabled"); + float statusSize = 5.7f * SCALE; + float statusW = ClickGuiRenderer.textWidth(medium, status, statusSize) + 12f * SCALE; + float statusX = x + w - 8f * SCALE - statusW; + int statusBg = enabled + ? SettingsGuiPalette.withAlpha(SettingsGuiPalette.mix(theme.accentSoft(), palette.panelPillActive(), 0.32f), 205) + : palette.panelPillBase(); + ClickGuiRenderer.drawRoundedRect(statusX, y + 7f * SCALE, statusW, 11f * SCALE, 5.5f * SCALE, statusBg); + ClickGuiRenderer.drawText(medium, status, statusX + 6f * SCALE, y + 9.6f * SCALE, + statusSize, enabled ? theme.accent() : palette.panelMuted(), false); + + float headingH = 29f * SCALE; + float footerH = 19f * SCALE; + float editorGap = 6f * SCALE; + float editorH = (h - headingH - footerH - editorGap * 2f) * 0.5f; + float editorY = y + headingH; + editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY) + .render(x + 6f * SCALE, editorY, w - 12f * SCALE, editorH, regular, medium, mx, my, palette, theme); + editorY += editorH + editorGap; + editor(selected, CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN) + .render(x + 6f * SCALE, editorY, w - 12f * SCALE, editorH, regular, medium, mx, my, palette, theme); + + String resetLabel = tr("reset_source", "Reset source"); + float resetW = Math.max(58f * SCALE, ClickGuiRenderer.textWidth(regular, resetLabel, 5.8f * SCALE) + 14f * SCALE); + reset = new Rect(x + w - 6f * SCALE - resetW, y + h - 15f * SCALE, resetW, 11f * SCALE); + resetHover = animate(resetHover, reset.contains(mx, my), 13f); + button(reset, resetLabel, resetHover, regular, palette, theme); + if (reset.contains(mx, my)) SystemCursor.set(SystemCursor.CursorType.HAND); + + String hint = focusedEditor == null + ? tr("edit_hint", "Click a pattern area to edit") + : tr("keyboard_hint", "Ctrl+A/C/X/V · Enter new line · Esc unfocus"); + ClickGuiRenderer.drawText(regular, hint, x + 8f * SCALE, y + h - 12.3f * SCALE, + 5.2f * SCALE, palette.panelMuted(), false); + } + + private void focus(InlineEditor editor) { + if (focusedEditor == editor) return; + if (focusedEditor != null) focusedEditor.setFocused(false); + focusedEditor = editor; + if (focusedEditor != null) focusedEditor.setFocused(true); + } + + private void blurEditor() { + if (focusedEditor != null) focusedEditor.setFocused(false); + focusedEditor = null; + } + + private void reloadEditors(CombatProtocolHeuristicSource source) { + CombatProtocolHeuristicsConfig config = CombatProtocolHeuristicsConfig.get(); + for (CombatProtocolHeuristicsConfig.ProtocolFamily family : CombatProtocolHeuristicsConfig.ProtocolFamily.values()) { + editor(source, family).replace(config.patterns(source, family)); + } + } + + private InlineEditor editor(CombatProtocolHeuristicSource source, + CombatProtocolHeuristicsConfig.ProtocolFamily family) { + return editors.get(source).get(family); + } + + private boolean sourceEnabled(CombatProtocolHeuristicSource source) { + return owner.sources() != null && owner.sources().get(source.key()); + } + + private final class InlineEditor { + private static final float LINE_H = 8f * SCALE; + private static final float FONT_SIZE = 5.9f * SCALE; + private static final float GUTTER_W = 19f * SCALE; + private static final float TEXT_PAD = 5f * SCALE; + + private final CombatProtocolHeuristicSource source; + private final CombatProtocolHeuristicsConfig.ProtocolFamily family; + private final StringBuilder buffer = new StringBuilder(); + private final TextSelection selection = new TextSelection(); + private Rect bounds = Rect.EMPTY; + private Rect textArea = Rect.EMPTY; + private int caret; + private int preferredColumn = -1; + private float scrollY; + private float targetScrollY; + private float scrollX; + private float targetScrollX; + private float focusAnim; + private float hoverAnim; + private float blink; + private boolean focused; + private boolean dragging; + + InlineEditor(CombatProtocolHeuristicSource source, + CombatProtocolHeuristicsConfig.ProtocolFamily family, + Set patterns) { + this.source = source; + this.family = family; + replace(patterns); + } + + void replace(Set patterns) { + buffer.setLength(0); + if (patterns != null && !patterns.isEmpty()) buffer.append(String.join("\n", patterns)); + caret = Math.min(caret, buffer.length()); + selection.clear(); + targetScrollX = targetScrollY = scrollX = scrollY = 0f; + } + + void setFocused(boolean value) { + focused = value; + blink = 0f; + if (!value) { + dragging = false; + selection.clear(); + } + } + + int patternCount() { + return parsedPatterns().size(); + } + + void render(float x, float y, float w, float h, TextRenderer regular, TextRenderer medium, + float mx, float my, SettingsGuiPalette palette, Themes.Theme theme) { + bounds = new Rect(x, y, w, h); + boolean hovered = bounds.contains(mx, my); + focusAnim = animate(focusAnim, focused, 13f); + hoverAnim = animate(hoverAnim, hovered, 13f); + blink += AnimationUtility.deltaTime(); + scrollY = AnimationUtility.approach(scrollY, targetScrollY, 0.28f); + scrollX = AnimationUtility.approach(scrollX, targetScrollX, 0.28f); + + int baseA = SettingsGuiPalette.mix(palette.moduleCardTop(), palette.panelBgLeft(), 0.30f); + int baseB = SettingsGuiPalette.mix(palette.moduleCardBottom(), palette.panelBgRight(), 0.28f); + int bgA = SettingsGuiPalette.mix(baseA, theme.accentSoft(), focusAnim * 0.14f + hoverAnim * 0.04f); + int bgB = SettingsGuiPalette.mix(baseB, theme.accent(), focusAnim * 0.08f + hoverAnim * 0.03f); + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, 5.5f * SCALE, bgA, bgB, 85f); + ClickGuiRenderer.drawRoundedRectStrokeGradient( + x, y, w, h, 5.5f * SCALE, (0.45f + focusAnim * 0.25f) * SCALE, + SettingsGuiPalette.mix(palette.panelStroke(), theme.accentSoft(), 0.18f + focusAnim * 0.66f), + SettingsGuiPalette.mix(palette.panelStroke(), theme.accent(), 0.10f + focusAnim * 0.54f), + 0f + ); + if (focused) { + ClickGuiRenderer.drawRoundedRectGlow(x, y, w, h, 5.5f * SCALE, 6f * SCALE, + SettingsGuiPalette.withAlpha(theme.accentSoft(), Math.round(25f + 25f * focusAnim))); + } + + boolean legacy = family == CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY; + String name = legacy ? tr("legacy", "Legacy (1.8)") : tr("modern", "Modern (1.9+)"); + int invalid = invalidCount(List.of(lines())); + String meta = tr("pattern_count", "%s patterns", patternCount()); + if (invalid > 0) meta += " · " + tr("invalid_count", "%s invalid", invalid); + ClickGuiRenderer.drawText(medium, name, x + 7f * SCALE, y + 5.5f * SCALE, + 6.7f * SCALE, palette.moduleTitleText(), false); + float metaSize = 5.3f * SCALE; + float metaW = ClickGuiRenderer.textWidth(regular, meta, metaSize); + ClickGuiRenderer.drawText(regular, meta, x + w - 7f * SCALE - metaW, y + 7f * SCALE, + metaSize, invalid > 0 ? 0xFFFF8A80 : palette.panelMuted(), false); + + float areaY = y + 19f * SCALE; + textArea = new Rect(x + 5f * SCALE, areaY, w - 10f * SCALE, h - 24f * SCALE); + int areaBg = SettingsGuiPalette.withAlpha(SettingsGuiPalette.darken(palette.panelBgRight(), 0.18f), 190); + ClickGuiRenderer.drawRoundedRect(textArea.x, textArea.y, textArea.w, textArea.h, 3.5f * SCALE, areaBg); + ClickGuiRenderer.drawRoundedRectStroke(textArea.x, textArea.y, textArea.w, textArea.h, 3.5f * SCALE, + 0.35f * SCALE, SettingsGuiPalette.mix(palette.panelStroke(), theme.accentSoft(), focusAnim * 0.42f)); + renderText(regular, palette, theme); + if (hovered) SystemCursor.set(SystemCursor.CursorType.TEXT); + } + + private void renderText(TextRenderer font, SettingsGuiPalette palette, Themes.Theme theme) { + String[] lines = lines(); + float viewX = textArea.x + GUTTER_W + TEXT_PAD - scrollX; + float viewY = textArea.y + 3f * SCALE - scrollY; + boolean clipped = ScissorFunction.pushRaw(textArea.x, textArea.y, textArea.w, textArea.h); + int globalOffset = 0; + int caretLine = 0; + int caretCol = 0; + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + float lineY = viewY + i * LINE_H; + int lineStart = globalOffset; + int lineEnd = lineStart + line.length(); + if (caret >= lineStart && caret <= lineEnd) { + caretLine = i; + caretCol = caret - lineStart; + } + + if (lineY + LINE_H >= textArea.y && lineY <= textArea.y + textArea.h) { + if (focused && selection.hasRange() && selection.appliesToLine(0)) { + int selStart = Math.min(selection.anchor(), selection.caret()); + int selEnd = Math.max(selection.anchor(), selection.caret()); + int a = Math.max(lineStart, selStart); + int b = Math.min(lineEnd, selEnd); + if (b > a) { + float x0 = viewX + ClickGuiRenderer.textWidth(font, line.substring(0, a - lineStart), FONT_SIZE); + float x1 = viewX + ClickGuiRenderer.textWidth(font, line.substring(0, b - lineStart), FONT_SIZE); + ClickGuiRenderer.drawRoundedRect(x0, lineY, Math.max(1f, x1 - x0), LINE_H, + 2f * SCALE, SettingsGuiPalette.withAlpha(theme.accentSoft(), 155)); + } + } + + String lineNumber = Integer.toString(i + 1); + float numberW = ClickGuiRenderer.textWidth(font, lineNumber, 5.1f * SCALE); + ClickGuiRenderer.drawText(font, lineNumber, textArea.x + GUTTER_W - 4f * SCALE - numberW, + lineY + 0.5f * SCALE, 5.1f * SCALE, palette.panelMuted(), false); + int textColor = line.isBlank() || validPattern(line) ? palette.moduleDescriptionText() : 0xFFFF8A80; + ClickGuiRenderer.drawText(font, line, viewX, lineY + 0.2f * SCALE, FONT_SIZE, textColor, false); + } + globalOffset = lineEnd + 1; + } + + ClickGuiRenderer.drawRect(textArea.x + GUTTER_W, textArea.y + 2f * SCALE, + 0.4f * SCALE, textArea.h - 4f * SCALE, palette.panelDivider()); + + if (focused && ((int) (blink * 2f) & 1) == 0) { + String line = lines[Math.max(0, Math.min(caretLine, lines.length - 1))]; + float caretX = viewX + ClickGuiRenderer.textWidth(font, line.substring(0, Math.min(caretCol, line.length())), FONT_SIZE); + float caretY = viewY + caretLine * LINE_H; + ClickGuiRenderer.drawRoundedRect(caretX, caretY, 0.65f * SCALE, LINE_H, + 0.3f * SCALE, theme.accent()); + } + if (clipped) ScissorFunction.pop(); + } + + boolean mousePressed(float mx, float my, int button) { + if (button != GLFW.GLFW_MOUSE_BUTTON_LEFT || !textArea.contains(mx, my)) return false; + int target = caretFromPoint(mx, my); + boolean shift = shiftDown(); + if (shift) { + if (!selection.hasCaret()) selection.begin(0, caret); + selection.updateCaret(0, target); + } else { + selection.clear(); + selection.begin(0, target); + } + caret = target; + preferredColumn = -1; + dragging = true; + blink = 0f; + ensureCaretVisible(); + return true; + } + + void mouseMoved(float mx, float my) { + if (!focused || !dragging) return; + int target = caretFromPoint(mx, my); + if (!selection.hasCaret()) selection.begin(0, caret); + selection.updateCaret(0, target); + caret = target; + preferredColumn = -1; + blink = 0f; + ensureCaretVisible(); + } + + void mouseReleased(int button) { + if (button == GLFW.GLFW_MOUSE_BUTTON_LEFT) dragging = false; + } + + boolean scroll(float mx, float my, double delta) { + if (!bounds.contains(mx, my)) return false; + targetScrollY -= (float) delta * LINE_H * 2f; + clampScroll(); + return true; + } + + boolean charTyped(char chr) { + if (!focused || Character.isISOControl(chr)) return false; + insertText(String.valueOf(chr)); + return true; + } + + boolean keyPressed(int key, int modifiers) { + if (!focused) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (ctrl) { + switch (key) { + case GLFW.GLFW_KEY_A -> selectAll(); + case GLFW.GLFW_KEY_C -> copy(); + case GLFW.GLFW_KEY_X -> cut(); + case GLFW.GLFW_KEY_V -> insertText(ClipboardUtil.get()); + default -> { + return true; + } + } + return true; + } + switch (key) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> insertText("\n"); + case GLFW.GLFW_KEY_BACKSPACE -> backspace(); + case GLFW.GLFW_KEY_DELETE -> deleteForward(); + case GLFW.GLFW_KEY_LEFT -> moveHorizontal(-1, shift); + case GLFW.GLFW_KEY_RIGHT -> moveHorizontal(1, shift); + case GLFW.GLFW_KEY_UP -> moveVertical(-1, shift); + case GLFW.GLFW_KEY_DOWN -> moveVertical(1, shift); + case GLFW.GLFW_KEY_HOME -> moveLineBoundary(false, shift); + case GLFW.GLFW_KEY_END -> moveLineBoundary(true, shift); + default -> { + return true; + } + } + return true; + } + + private void insertText(String text) { + if (text == null || text.isEmpty()) return; + deleteSelection(); + String normalized = text.replace("\r\n", "\n").replace('\r', '\n'); + buffer.insert(caret, normalized); + caret += normalized.length(); + selection.clear(); + preferredColumn = -1; + blink = 0f; + changed(); + } + + private void backspace() { + if (deleteSelection()) { + changed(); + return; + } + if (caret <= 0) return; + buffer.deleteCharAt(caret - 1); + caret--; + selection.clear(); + preferredColumn = -1; + changed(); + } + + private void deleteForward() { + if (deleteSelection()) { + changed(); + return; + } + if (caret >= buffer.length()) return; + buffer.deleteCharAt(caret); + selection.clear(); + preferredColumn = -1; + changed(); + } + + private boolean deleteSelection() { + if (!selection.hasRange()) return false; + int start = clamp(Math.min(selection.anchor(), selection.caret()), 0, buffer.length()); + int end = clamp(Math.max(selection.anchor(), selection.caret()), 0, buffer.length()); + if (end <= start) return false; + buffer.delete(start, end); + caret = start; + selection.clear(); + return true; + } + + private void moveHorizontal(int delta, boolean extend) { + int target; + if (!extend && selection.hasRange()) { + target = delta < 0 + ? Math.min(selection.anchor(), selection.caret()) + : Math.max(selection.anchor(), selection.caret()); + } else { + target = clamp(caret + delta, 0, buffer.length()); + } + updateSelection(target, extend); + caret = target; + preferredColumn = -1; + blink = 0f; + ensureCaretVisible(); + } + + private void moveVertical(int delta, boolean extend) { + String[] lines = lines(); + LineColumn current = lineColumn(caret, lines); + if (preferredColumn < 0) preferredColumn = current.column; + int targetLine = clamp(current.line + delta, 0, lines.length - 1); + int target = indexForLineColumn(lines, targetLine, Math.min(preferredColumn, lines[targetLine].length())); + updateSelection(target, extend); + caret = target; + blink = 0f; + ensureCaretVisible(); + } + + private void moveLineBoundary(boolean end, boolean extend) { + String[] lines = lines(); + LineColumn current = lineColumn(caret, lines); + int target = indexForLineColumn(lines, current.line, end ? lines[current.line].length() : 0); + updateSelection(target, extend); + caret = target; + preferredColumn = -1; + blink = 0f; + ensureCaretVisible(); + } + + private void updateSelection(int target, boolean extend) { + if (extend) { + if (!selection.hasCaret()) selection.begin(0, caret); + selection.updateCaret(0, target); + } else { + selection.clear(); + } + } + + private void selectAll() { + selection.begin(0, 0); + selection.updateCaret(0, buffer.length()); + caret = buffer.length(); + preferredColumn = -1; + blink = 0f; + ensureCaretVisible(); + } + + private void copy() { + if (selection.hasRange()) { + int start = clamp(Math.min(selection.anchor(), selection.caret()), 0, buffer.length()); + int end = clamp(Math.max(selection.anchor(), selection.caret()), 0, buffer.length()); + if (end > start) ClipboardUtil.copy(buffer.substring(start, end)); + } else if (!buffer.isEmpty()) { + ClipboardUtil.copy(buffer.toString()); + } + } + + private void cut() { + if (!selection.hasRange()) return; + copy(); + if (deleteSelection()) changed(); + } + + private void changed() { + CombatProtocolHeuristicsConfig.get().setPatterns(source, family, parsedPatterns()); + clampScroll(); + ensureCaretVisible(); + } + + private Set parsedPatterns() { + LinkedHashSet out = new LinkedHashSet<>(); + for (String line : lines()) { + String clean = line.trim(); + if (!clean.isEmpty()) out.add(clean); + } + return out; + } + + private int caretFromPoint(float mx, float my) { + String[] lines = lines(); + int line = clamp((int) Math.floor((my - textArea.y - 3f * SCALE + scrollY) / LINE_H), 0, lines.length - 1); + String text = lines[line]; + float localX = mx - (textArea.x + GUTTER_W + TEXT_PAD) + scrollX; + int column = 0; + TextRenderer font = ClickGuiRenderer.getInterRegular(); + for (int i = 1; i <= text.length(); i++) { + float prev = ClickGuiRenderer.textWidth(font, text.substring(0, i - 1), FONT_SIZE); + float next = ClickGuiRenderer.textWidth(font, text.substring(0, i), FONT_SIZE); + if (localX < (prev + next) * 0.5f) break; + column = i; + } + return indexForLineColumn(lines, line, column); + } + + private void ensureCaretVisible() { + String[] lines = lines(); + LineColumn pos = lineColumn(caret, lines); + float caretY = pos.line * LINE_H; + float visibleH = Math.max(LINE_H, textArea.h - 6f * SCALE); + if (caretY < targetScrollY) targetScrollY = caretY; + if (caretY + LINE_H > targetScrollY + visibleH) targetScrollY = caretY + LINE_H - visibleH; + + TextRenderer font = ClickGuiRenderer.getInterRegular(); + String line = lines[pos.line]; + float caretX = ClickGuiRenderer.textWidth(font, line.substring(0, Math.min(pos.column, line.length())), FONT_SIZE); + float visibleW = Math.max(20f * SCALE, textArea.w - GUTTER_W - TEXT_PAD * 2f); + if (caretX < targetScrollX) targetScrollX = caretX; + if (caretX + 2f * SCALE > targetScrollX + visibleW) targetScrollX = caretX + 2f * SCALE - visibleW; + clampScroll(); + } + + private void clampScroll() { + String[] lines = lines(); + float maxY = Math.max(0f, lines.length * LINE_H - Math.max(LINE_H, textArea.h - 6f * SCALE)); + float maxLineW = 0f; + TextRenderer font = ClickGuiRenderer.getInterRegular(); + for (String line : lines) maxLineW = Math.max(maxLineW, ClickGuiRenderer.textWidth(font, line, FONT_SIZE)); + float maxX = Math.max(0f, maxLineW - Math.max(20f * SCALE, textArea.w - GUTTER_W - TEXT_PAD * 2f)); + targetScrollY = clamp(targetScrollY, 0f, maxY); + targetScrollX = clamp(targetScrollX, 0f, maxX); + } + + private String[] lines() { + return buffer.toString().split("\n", -1); + } + } + + private static void panel(float x, float y, float w, float h, SettingsGuiPalette palette) { + ClickGuiRenderer.drawBlur(x, y, w, h, 5f * SCALE, palette.panelBlurTint(), 175f / 255f); + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, 6f * SCALE, + palette.panelBgLeft(), palette.panelBgRight(), 0f); + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, 6f * SCALE, + 0.5f * SCALE, palette.panelStroke()); + } + + private static void button(Rect rect, String label, float hover, TextRenderer font, + SettingsGuiPalette palette, Themes.Theme theme) { + int bg = SettingsGuiPalette.mix(palette.panelPillBase(), theme.accentSoft(), 0.08f + hover * 0.24f); + ClickGuiRenderer.drawRoundedRect(rect.x, rect.y, rect.w, rect.h, 3.5f * SCALE, bg); + ClickGuiRenderer.drawRoundedRectStroke(rect.x, rect.y, rect.w, rect.h, 3.5f * SCALE, + 0.4f * SCALE, SettingsGuiPalette.mix(palette.panelStroke(), theme.accent(), hover * 0.42f)); + float size = 5.8f * SCALE; + float tw = ClickGuiRenderer.textWidth(font, label, size); + float th = ClickGuiRenderer.textHeight(font, size); + ClickGuiRenderer.drawText(font, label, rect.x + (rect.w - tw) * 0.5f, + rect.y + (rect.h - th) * 0.5f, size, + SettingsGuiPalette.mix(palette.panelText(), theme.accent(), hover * 0.55f), false); + } + + private static float animate(float value, boolean active, float speed) { + float target = active ? 1f : 0f; + return AnimationUtility.snap( + AnimationUtility.approach(value, target, AnimationUtility.deltaTime(), speed), + target, + 0.01f + ); + } + + private static float easeOutCubic(float value) { + float t = clamp(value, 0f, 1f); + float inv = 1f - t; + return 1f - inv * inv * inv; + } + + private static int invalidCount(Iterable patterns) { + int invalid = 0; + if (patterns == null) return invalid; + for (String pattern : patterns) { + if (pattern != null && !pattern.isBlank() && !validPattern(pattern)) invalid++; + } + return invalid; + } + + private static boolean validPattern(String pattern) { + if (pattern == null || pattern.isBlank()) return false; + try { + Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + return true; + } catch (PatternSyntaxException ignored) { + return false; + } + } + + private static String sourceName(CombatProtocolHeuristicSource source) { + return ClickGuiI18n.tr( + "setting.common.combat.protocol_heuristics.option." + source.key(), + switch (source) { + case MESSAGES -> "Messages"; + case OVERLAY -> "Overlay"; + case BOSSBAR -> "Boss bar"; + } + ); + } + + private static String defaultSourceHint(CombatProtocolHeuristicSource source) { + return switch (source) { + case MESSAGES -> "Chat and system messages received from the server"; + case OVERLAY -> "Action bar and overlay text"; + case BOSSBAR -> "Visible boss bar titles"; + }; + } + + private static String tr(String suffix, String fallback, Object... args) { + return ClickGuiI18n.tr("clickgui.protocol_heuristics." + suffix, fallback, args); + } + + private static boolean shiftDown() { + long window = net.minecraft.client.Minecraft.getInstance().getWindow().handle(); + return GLFW.glfwGetKey(window, GLFW.GLFW_KEY_LEFT_SHIFT) == GLFW.GLFW_PRESS + || GLFW.glfwGetKey(window, GLFW.GLFW_KEY_RIGHT_SHIFT) == GLFW.GLFW_PRESS; + } + + private static LineColumn lineColumn(int index, String[] lines) { + int remaining = clamp(index, 0, totalLength(lines)); + for (int line = 0; line < lines.length; line++) { + int length = lines[line].length(); + if (remaining <= length) return new LineColumn(line, remaining); + remaining -= length + 1; + } + int last = Math.max(0, lines.length - 1); + return new LineColumn(last, lines[last].length()); + } + + private static int indexForLineColumn(String[] lines, int line, int column) { + int index = 0; + int resolvedLine = clamp(line, 0, lines.length - 1); + for (int i = 0; i < resolvedLine; i++) index += lines[i].length() + 1; + return index + clamp(column, 0, lines[resolvedLine].length()); + } + + private static int totalLength(String[] lines) { + int length = 0; + for (int i = 0; i < lines.length; i++) { + length += lines[i].length(); + if (i < lines.length - 1) length++; + } + return length; + } + + private static boolean inside(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } + + private record SourceHit(CombatProtocolHeuristicSource source, Rect card) { + } + + private record LineColumn(int line, int column) { + } + + private record Rect(float x, float y, float w, float h) { + static final Rect EMPTY = new Rect(0f, 0f, 0f, 0f); + + boolean contains(float mx, float my) { + return inside(mx, my, x, y, w, h); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ClickGuiSection.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ClickGuiSection.java new file mode 100644 index 0000000..5949af5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ClickGuiSection.java @@ -0,0 +1,41 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.sections; + +import java.nio.file.Path; +import java.util.List; + +public interface ClickGuiSection { + void layout(float x, float y, float w, float h); + + void render(float mouseX, float mouseY); + + void renderGlassPass(float alphaFactor); + + boolean mousePressed(float mouseX, float mouseY, int button); + + void mouseReleased(float mouseX, float mouseY, int button); + + boolean mouseScrolled(float mouseX, float mouseY, double amount); + + boolean keyPressed(int keyCode, int scanCode, int modifiers); + + boolean charTyped(char chr, int modifiers); + + default boolean onFilesDrop(List paths) { + return false; + } + + void onSelected(); + + void onDeselected(); + + default boolean isVisible() { + return true; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ModulesSection.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ModulesSection.java new file mode 100644 index 0000000..f634b2f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/ModulesSection.java @@ -0,0 +1,70 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.sections; + +import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.ModulesMenuScreen; + +public final class ModulesSection implements ClickGuiSection { + private final ModulesMenuScreen screen = new ModulesMenuScreen(); + + @Override + public void layout(float x, float y, float w, float h) { + screen.layout(x, y, w, h); + } + + @Override + public void render(float mouseX, float mouseY) { + screen.render(mouseX, mouseY); + } + + @Override + public void renderGlassPass(float alphaFactor) { + screen.renderGlassPass(alphaFactor); + } + + @Override + public boolean mousePressed(float mouseX, float mouseY, int button) { + return screen.mousePressed(mouseX, mouseY, button); + } + + @Override + public void mouseReleased(float mouseX, float mouseY, int button) { + screen.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseScrolled(float mouseX, float mouseY, double amount) { + return screen.mouseScrolled(mouseX, mouseY, amount); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return screen.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return screen.charTyped(chr, modifiers); + } + + @Override + public void onSelected() { + screen.open(); + } + + @Override + public void onDeselected() { + screen.close(); + } + + @Override + public boolean isVisible() { + return screen.isVisible(); + } +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/SettingsSection.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/SettingsSection.java new file mode 100644 index 0000000..29f09ca --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/SettingsSection.java @@ -0,0 +1,104 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.sections; + +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.clickgui.sections.settings.SettingsTabRuntime; + +import java.nio.file.Path; +import java.util.List; + +public final class SettingsSection implements ClickGuiSection { + private float x; + private float y; + private float w; + private float h; + + @Override + public void layout(float x, float y, float w, float h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + + @Override + public void render(float mouseX, float mouseY) { + SettingsTabRuntime.render(x, y, w, h, mouseX, mouseY); + } + + @Override + public void renderGlassPass(float alphaFactor) { + } + + @Override + public boolean mousePressed(float mouseX, float mouseY, int button) { + if (SettingsTabRuntime.isColorEditorOpen()) { + SettingsTabRuntime.handleColorEditorMouseButton(mouseX, mouseY, button, true); + return true; + } + return SettingsTabRuntime.mousePressed(mouseX, mouseY, button); + } + + @Override + public void mouseReleased(float mouseX, float mouseY, int button) { + if (SettingsTabRuntime.isColorEditorOpen()) { + SettingsTabRuntime.handleColorEditorMouseButton(mouseX, mouseY, button, false); + return; + } + SettingsTabRuntime.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseScrolled(float mouseX, float mouseY, double amount) { + if (SettingsTabRuntime.isColorEditorOpen()) { + SettingsTabRuntime.handleColorEditorScroll(mouseX, mouseY, amount); + return true; + } + SettingsTabRuntime.mouseScrolled(mouseX, mouseY, amount); + return true; + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (SettingsTabRuntime.isColorEditorOpen()) { + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + SettingsTabRuntime.closeColorEditor(); + } + return true; + } + return SettingsTabRuntime.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return SettingsTabRuntime.charTyped(chr, modifiers); + } + + @Override + public boolean onFilesDrop(List paths) { + return SettingsTabRuntime.onFilesDrop(paths); + } + + @Override + public void onSelected() { + SettingsTabRuntime.open(); + SettingsTabRuntime.markDirty(); + } + + @Override + public void onDeselected() { + SettingsTabRuntime.mouseReleased(0f, 0f, 0); + SettingsTabRuntime.close(); + } + + @Override + public boolean isVisible() { + return SettingsTabRuntime.isVisible(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/settings/SettingsTabRuntime.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/settings/SettingsTabRuntime.java new file mode 100644 index 0000000..235aae9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/sections/settings/SettingsTabRuntime.java @@ -0,0 +1,106 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.sections.settings; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.MenuScreen; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.nio.file.Path; +import java.util.List; + +public enum SettingsTabRuntime { + ; + private static final MenuScreen SCREEN = new MenuScreen(); + + public static void render(float areaX, float areaY, float areaW, float areaH, float mx, float my) { + SCREEN.render(areaX, areaY, areaW, areaH, mx, my); + } + + public static void open() { + SCREEN.open(); + } + + public static void close() { + SCREEN.close(); + } + + public static boolean isVisible() { + return SCREEN.isVisible(); + } + + public static boolean mousePressed(float mx, float my, int button) { + return SCREEN.mouseClicked(mx, my, button); + } + + public static void mouseReleased(float mx, float my, int button) { + SCREEN.mouseReleased(mx, my, button); + } + + public static void mouseScrolled(float mx, float my, double delta) { + SCREEN.mouseScrolled(mx, my, delta); + } + + public static boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return SCREEN.keyPressed(keyCode, scanCode, modifiers); + } + + public static boolean charTyped(char chr, int modifiers) { + return SCREEN.charTyped(chr, modifiers); + } + + public static boolean onFilesDrop(List paths) { + return SCREEN.onFilesDrop(paths); + } + + public static void renderEditor(Renderer2D rendererIn, + TextRenderer fallback, + GuiGraphicsExtractor ctx, + float tickDelta, + int fbw, + int fbh, + float mx, + float my) { + // Visual-only iteration: settings/editor window is intentionally not connected yet. + } + + public static void handleEditorMouseButton(float mx, float my, int button, boolean pressed) { + } + + public static void handleEditorMouseScroll(float mx, float my, double delta) { + } + + public static boolean isEditorOpen() { + return false; + } + + public static void closeGuiEditor() { + } + + public static boolean isColorEditorOpen() { + return false; + } + + public static void closeColorEditor() { + } + + public static void handleColorEditorMouseButton(float mx, float my, int button, boolean pressed) { + } + + public static void handleColorEditorScroll(float mx, float my, double delta) { + } + + public static void markDirty() { + SCREEN.markDirty(); + } + + public static void clearScreens() { + SCREEN.clearScreens(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/BooleanSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/BooleanSetting.java new file mode 100644 index 0000000..1402644 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/BooleanSetting.java @@ -0,0 +1,74 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.BooleanValue; + +public class BooleanSetting extends Setting { + + private final BooleanValue value; + private final UiState ui = new UiState(); + + public BooleanSetting(String name, BooleanValue value) { + super(name, value); + this.value = value; + this.ui.anim = value.get() ? 1f : 0f; + } + + public boolean get() { + return value.get(); + } + + public void set(boolean v) { + value.set(v); + } + + UiState ui() { + return ui; + } + + @Override + public Object save() { + return value == null ? null : value.toJson(); + } + + @Override + public void load(Object o) { + if (value != null) value.fromJson(o); + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int b) { + SettingRendererBridge.mouseClicked(this, mx, my, b); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + static final class UiState { + float lastX, lastY, lastW; + float lastH; + float lastControlX, lastControlY, lastControlW, lastControlH; + float anim = 0f; + float hoverAnim = 0f; + float layoutW = Float.NaN; + String layoutLabel = null; + String[] layoutLines = new String[]{""}; + float layoutLineH = 0f; + float layoutLabelY = 0f; + float layoutControlY = 0f; + float layoutRowH = 44f; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorPresetStore.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorPresetStore.java new file mode 100644 index 0000000..1a6e092 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorPresetStore.java @@ -0,0 +1,140 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import com.google.gson.*; +import dev.loki.lovisual.config.ConfigPaths; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +enum ColorPresetStore { + ; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + private static final Path FILE = ConfigPaths.root().resolve("color_presets.json"); + private static final int MAX_PRESETS = 24; + private static final List PRESETS = new ArrayList<>(); + private static boolean loaded; + + static List presets() { + ensureLoaded(); + return Collections.unmodifiableList(PRESETS); + } + + static void add(int argb) { + ensureLoaded(); + int normalized = argb; + PRESETS.removeIf(c -> c == normalized); + PRESETS.add(0, normalized); + while (PRESETS.size() > MAX_PRESETS) PRESETS.remove(PRESETS.size() - 1); + save(); + } + + static boolean removeClosest(int argb) { + ensureLoaded(); + if (PRESETS.isEmpty()) return false; + int bestIndex = -1; + int bestDistance = Integer.MAX_VALUE; + for (int i = 0; i < PRESETS.size(); i++) { + int distance = colorDistance(PRESETS.get(i), argb); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + if (bestIndex < 0) return false; + PRESETS.remove(bestIndex); + save(); + return true; + } + + static boolean removeExact(int argb) { + ensureLoaded(); + boolean removed = PRESETS.removeIf(c -> c == argb); + if (removed) save(); + return removed; + } + + private static void ensureLoaded() { + if (loaded) return; + loaded = true; + PRESETS.clear(); + PRESETS.add(0xFFFFFFFF); + PRESETS.add(0xFFFF4F6D); + PRESETS.add(0xFFFFA94D); + PRESETS.add(0xFFFFE066); + PRESETS.add(0xFF69DB7C); + PRESETS.add(0xFF4DABF7); + PRESETS.add(0xFF845EF7); + PRESETS.add(0xFFFF6BCB); + if (!Files.isRegularFile(FILE)) { + save(); + return; + } + try { + String text = Files.readString(FILE); + JsonElement root = JsonParser.parseString(text); + JsonArray arr = root.isJsonArray() ? root.getAsJsonArray() + : root.isJsonObject() && root.getAsJsonObject().has("presets") ? root.getAsJsonObject().getAsJsonArray("presets") + : null; + if (arr == null) return; + PRESETS.clear(); + for (JsonElement el : arr) { + if (el == null || !el.isJsonPrimitive()) continue; + Integer parsed = parseColor(el.getAsString()); + if (parsed == null) continue; + if (!PRESETS.contains(parsed)) PRESETS.add(parsed); + if (PRESETS.size() >= MAX_PRESETS) break; + } + if (PRESETS.isEmpty()) PRESETS.add(0xFFFFFFFF); + } catch (Throwable t) { + DebugLog.warn("[LoVisual][ColorPresets] Failed to load %s: %s", FILE, t.getMessage()); + } + } + + private static void save() { + try { + Files.createDirectories(FILE.getParent()); + JsonObject root = new JsonObject(); + JsonArray arr = new JsonArray(); + for (int color : PRESETS) { + arr.add(String.format("#%08X", color)); + } + root.add("presets", arr); + Files.writeString(FILE, GSON.toJson(root)); + } catch (Throwable t) { + DebugLog.warn("[LoVisual][ColorPresets] Failed to save %s: %s", FILE, t.getMessage()); + } + } + + private static Integer parseColor(String raw) { + if (raw == null) return null; + String s = raw.trim(); + if (s.startsWith("#")) s = s.substring(1); + if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2); + try { + long v = Long.parseUnsignedLong(s, 16); + if (s.length() == 6) return (int) (0xFF000000L | v); + if (s.length() == 8) return (int) v; + } catch (Throwable ignored) { + } + return null; + } + + private static int colorDistance(int a, int b) { + int da = ((a >>> 24) & 0xFF) - ((b >>> 24) & 0xFF); + int dr = ((a >>> 16) & 0xFF) - ((b >>> 16) & 0xFF); + int dg = ((a >>> 8) & 0xFF) - ((b >>> 8) & 0xFF); + int db = (a & 0xFF) - (b & 0xFF); + return da * da + dr * dr + dg * dg + db * db; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSetting.java new file mode 100644 index 0000000..78b8c39 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSetting.java @@ -0,0 +1,143 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.ColorValue; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.util.text.TextSelection; + +public class ColorSetting extends Setting { + + private final ColorValue value; + private final UiState ui = new UiState(); + + public ColorSetting(String name, ColorValue value) { + super(name, asConfigValue(value)); + this.value = value; + } + + private static ConfigValue asConfigValue(ColorValue value) { + return value instanceof ConfigValue configValue ? configValue : null; + } + + ColorValue value() { + return value; + } + + UiState ui() { + return ui; + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int button) { + SettingRendererBridge.mouseClicked(this, mx, my, button); + } + + @Override + public void mouseReleased(double mx, double my, int button) { + SettingRendererBridge.mouseReleased(this, mx, my, button); + } + + @Override + public void mouseClickedOutside(double mx, double my, int button) { + SettingRendererBridge.mouseClickedOutside(this, mx, my, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return SettingRendererBridge.keyPressed(this, keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return SettingRendererBridge.charTyped(this, chr, modifiers); + } + + @Override + public boolean mouseScrolled(double mx, double my, double amount) { + return SettingRendererBridge.mouseScrolled(this, mx, my, amount); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + static final class UiState { + final TextSelection hexSelection = new TextSelection(); + boolean editing = false; + boolean showRgbBars = false; + boolean rainbow = false; + boolean sbFocused = false; + boolean hFocused = false; + boolean aFocused = false; + boolean rFocused = false; + boolean gFocused = false; + boolean bFocused = false; + boolean aRgbFocused = false; + float hue = 0f; + float saturation = 1f; + float brightness = 1f; + int alpha = 255; + int red = 255; + int green = 255; + int blue = 255; + int lastArgb = 0; + float previewGlowAnim = 0f; + float expandAnim = 0f; + float sliderHoverAnim = 0f; + float rnbHoverAnim = 0f; + float rnbOffAnim = 0f; + float rgbExpandAnim = 0f; + float lastX, lastY, lastW; + float popupX, popupY, popupW, popupH; + float moduleHoverAnim; + float squareX, squareY, squareW, squareH; + float hueX, hueY, hueW, hueH; + float alphaX, alphaY, alphaW, alphaH; + float rX, rY, rW, rH; + float gX, gY, gW, gH; + float bX, bY, bW, bH; + float aX, aY, aW, aH; + float rgbBottom; + float sliderX, sliderY, sliderW, sliderH; + float rnbX, rnbY, rnbW, rnbH; + boolean hexFocused = false; + String hexBuffer = ""; + int hexCursor = 0; + float hexAnim = 0f; + float hexErrorAnim = 0f; + float hexCursorBlink = 0f; + float presetHoverAnim = 0f; + float savePresetHoverAnim = 0f; + float deletePresetHoverAnim = 0f; + boolean presetsExpanded = false; + float presetsExpandAnim = 0f; + float presetsScroll = 0f; + float presetsMaxScroll = 0f; + float presetsContentH = 0f; + float presetsViewportH = 0f; + float presetsToggleHoverAnim = 0f; + float presetsScrollbarHoverAnim = 0f; + boolean presetsScrollbarDragging = false; + float presetsScrollbarDragOffset = 0f; + float hexX, hexY, hexW, hexH; + float presetX, presetY, presetW, presetH; + float presetListX, presetListY, presetListW, presetListH; + float presetToggleX, presetToggleY, presetToggleW, presetToggleH; + float presetScrollbarX, presetScrollbarY, presetScrollbarW, presetScrollbarH; + float presetScrollbarHandleY, presetScrollbarHandleH; + float savePresetX, savePresetY, savePresetW, savePresetH; + float deletePresetX, deletePresetY, deletePresetW, deletePresetH; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSettingNoAlpha.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSettingNoAlpha.java new file mode 100644 index 0000000..b43a3df --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ColorSettingNoAlpha.java @@ -0,0 +1,17 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.RGBColorValue; + +public class ColorSettingNoAlpha extends ColorSetting { + + public ColorSettingNoAlpha(String name, RGBColorValue value) { + super(name, value); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ConfigColorSettingNoAlpha.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ConfigColorSettingNoAlpha.java new file mode 100644 index 0000000..49e31ac --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ConfigColorSettingNoAlpha.java @@ -0,0 +1,34 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.ConfigObject; +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.config.values.RGBColorValue; + +/** + * Color setting that persists changes via a ConfigObject (non-Module owner). + */ +public class ConfigColorSettingNoAlpha extends ColorSettingNoAlpha { + + private final ConfigObject owner; + + public ConfigColorSettingNoAlpha(String name, RGBColorValue value, ConfigObject owner) { + super(name, value); + this.owner = owner; + } + + @Override + public void mouseReleased(double mx, double my, int button) { + super.mouseReleased(mx, my, button); + if (button != 0) return; + if (owner != null) { + ConfigSerializer.requestSave(owner); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/CooldownRulesSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/CooldownRulesSetting.java new file mode 100644 index 0000000..3e7025a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/CooldownRulesSetting.java @@ -0,0 +1,592 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import dev.loki.lovisual.config.values.*; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.util.wav.ClickGuiSounds; + +import java.util.*; + +/** + * Picker-backed editor for per-item cooldown rules. + *

+ * Grid clicks only focus an item. Adding/removing a rule and editing its fields happens in the + * picker detail panel. The field rows intentionally reuse the regular settings renderer instead + * of custom miniature buttons, and numeric values use the normal slider setting. + */ +public final class CooldownRulesSetting extends TextListSetting implements PickerDetailOwner { + + private static final float DETAIL_W = 190f * 2f; + private static final float PANEL_PAD = 10f; + private static final float HEADER_H = 112f; + private static final float DETAIL_SCROLL_STEP = 18f; + private static final float DETAIL_SCROLLBAR_W = 3.25f; + + private final ItemCooldownRulesValue rules; + private final Ui ui = new Ui(); + private final Map editors = new HashMap<>(); + private String focusedItemId; + private float detailScroll; + private float detailMaxScroll; + + public CooldownRulesSetting(String name, ItemCooldownRulesValue rules) { + super(name, bridgeValue(rules), TextListSetting.PickerMode.ITEMS); + this.rules = rules; + syncBridge(); + } + + private static SetValue bridgeValue(ItemCooldownRulesValue rules) { + return new SetValue(rules != null ? rules.getName() + "_picker_items" : "cooldown_rules_picker_items", + rules != null ? rules.getItemIds() : Set.of()); + } + + private static float computeSettingsContentHeight(List settings) { + if (settings == null || settings.isEmpty()) return 0f; + float h = 0f; + for (int i = 0; i < settings.size(); i++) { + h += settings.get(i).getHeight(); + if (i + 1 < settings.size()) h += 2f; + } + return h; + } + + private static Item resolveItem(String itemId) { + try { + Identifier id = Identifier.tryParse(itemId); + if (id == null) return Items.AIR; + return BuiltInRegistries.ITEM.getValue(id); + } catch (Throwable ignored) { + return Items.AIR; + } + } + + private static String fit(TextRenderer font, String text, float size, float width) { + return ClickGuiRenderer.fitText(font, text == null ? "" : text, size, Math.max(1f, width)); + } + + private static boolean inside(double mx, double my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private static ItemCooldownRulesValue.Scope scopeById(String id) { + if (id != null) { + for (ItemCooldownRulesValue.Scope scope : ItemCooldownRulesValue.Scope.values()) { + if (scope.getId().equals(id) || scope.name().equalsIgnoreCase(id)) return scope; + } + } + return ItemCooldownRulesValue.Scope.PVP_GRACE; + } + + private static ItemCooldownRulesValue.Trigger triggerById(String id) { + if (id != null) { + for (ItemCooldownRulesValue.Trigger trigger : ItemCooldownRulesValue.Trigger.values()) { + if (trigger.getId().equals(id) || trigger.name().equalsIgnoreCase(id)) return trigger; + } + } + return ItemCooldownRulesValue.Trigger.CONSUME_FINISH; + } + + private static ItemCooldownRulesValue.UseBlockMode blockById(String id) { + if (id != null) { + for (ItemCooldownRulesValue.UseBlockMode mode : ItemCooldownRulesValue.UseBlockMode.values()) { + if (mode.getId().equals(id) || mode.name().equalsIgnoreCase(id)) return mode; + } + } + return ItemCooldownRulesValue.UseBlockMode.NONE; + } + + @Override + public ConfigValue getConfigValue() { + return rules; + } + + @Override + public Object save() { + return rules.toJson(); + } + + @Override + public void load(Object o) { + rules.fromJson(o); + editors.clear(); + syncBridge(); + } + + @Override + public Set getValueSet() { + return rules == null ? new LinkedHashSet<>() : rules.getItemIds(); + } + + @Override + public void setValueSet(Set next) { + if (rules == null) return; + + Set normalized = new LinkedHashSet<>(); + if (next != null) { + for (String raw : next) { + String id = normalizeEntry(raw); + if (id != null && !id.isBlank()) normalized.add(id); + } + } + + for (String existing : new ArrayList<>(rules.getItemIds())) { + if (!normalized.contains(existing)) { + rules.removeRule(existing); + editors.remove(existing); + } + } + + for (String id : normalized) { + rules.ensureRule(id); + } + + if (focusedItemId != null && !normalized.contains(focusedItemId) && normalized.isEmpty()) { + focusedItemId = null; + } + + syncBridge(); + saveParentConfig(); + } + + @Override + public String normalizeEntry(String line) { + String normalized = ItemCooldownRulesValue.normalizeItemId(line); + return normalized != null ? normalized : ""; + } + + @Override + public String getEditorText() { + return String.join("\n", getValueSet()); + } + + @Override + public float pickerDetailWidth() { + return DETAIL_W; + } + + @Override + public boolean shouldToggleSelectionOnCardClick() { + return false; + } + + @Override + public void onPickerFocusChanged(String id) { + String normalized = normalizeEntry(id); + if (normalized == null || normalized.isBlank()) return; + if (!normalized.equals(focusedItemId)) { + detailScroll = 0f; + } + focusedItemId = normalized; + } + + @Override + public void renderPickerDetails(float x, float y, float w, float h, String focusedId, float mouseX, float mouseY) { + UnifiedSettingsSkin.syncTheme(); + ui.actionHits.clear(); + ui.settingHits.clear(); + + String itemId = resolveFocusedId(focusedId); + drawPanel(x, y, w, h, itemId, mouseX, mouseY); + } + + @Override + public boolean mouseClickedPickerDetails(float mouseX, float mouseY, int button) { + if (button != 0) return false; + + for (ActionHit hit : new ArrayList<>(ui.actionHits)) { + if (!inside(mouseX, mouseY, hit.x, hit.y, hit.w, hit.h)) continue; + handleAction(hit.action, hit.itemId); + return true; + } + + for (SettingHit hit : new ArrayList<>(ui.settingHits)) { + if (!inside(mouseX, mouseY, hit.x, hit.y, hit.w, hit.h)) continue; + hit.setting.mouseClicked(mouseX, mouseY, button); + return true; + } + + return false; + } + + @Override + public void mouseReleasedPickerDetails(float mouseX, float mouseY, int button) { + for (SettingHit hit : new ArrayList<>(ui.settingHits)) { + hit.setting.mouseReleased(mouseX, mouseY, button); + } + } + + @Override + public boolean mouseScrolledPickerDetails(float mouseX, float mouseY, double delta) { + if (detailMaxScroll <= 0.5f) return true; + detailScroll -= (float) (delta * DETAIL_SCROLL_STEP); + clampDetailScroll(); + return true; + } + + private void drawPanel(float x, float y, float w, float h, String itemId, float mx, float my) { + ClickGuiRenderer.drawRoundedRect(x, y, w, h, 8f, UnifiedSettingsSkin.SURFACE_SOFT); + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, 8f, 0.8f, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 70)); + + TextRenderer titleFont = UnifiedSettingsSkin.fontSemibold(); + float titleSize = 20f; + ClickGuiRenderer.drawText(titleFont, "Cooldown Rule", x + PANEL_PAD, y + 13f, titleSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + float dividerY = y + 39f; + ClickGuiRenderer.drawRoundedRect(x + PANEL_PAD, dividerY, w - PANEL_PAD * 2f, 1f, 0.5f, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 48)); + + if (itemId == null || itemId.isBlank() || rules == null) { + detailScroll = 0f; + detailMaxScroll = 0f; + drawEmptyDetails(x, y, w, h); + return; + } + + Item item = resolveItem(itemId); + ItemStack stack = item == Items.AIR ? ItemStack.EMPTY : item.getDefaultInstance(); + String name = stack.isEmpty() ? itemId : stack.getHoverName().getString(); + boolean hasRule = rules.containsRule(itemId); + + drawItemHeader(x, y, w, itemId, name, stack, hasRule, mx, my); + + if (!hasRule) { + detailScroll = 0f; + detailMaxScroll = 0f; + drawMissingRuleHint(x, y, w, h); + return; + } + + RuleEditor editor = editorFor(itemId); + if (!editor.isDraggingSlider()) { + editor.syncFromRule(); + } + + float contentX = x + PANEL_PAD; + float contentY = y + HEADER_H; + float contentH = Math.max(1f, h - HEADER_H - PANEL_PAD); + float totalContentH = computeSettingsContentHeight(editor.settings); + detailMaxScroll = Math.max(0f, totalContentH - contentH); + clampDetailScroll(); + + float scrollbarReserve = detailMaxScroll > 0.5f ? DETAIL_SCROLLBAR_W + 5f : 0f; + float contentW = w - PANEL_PAD * 2f - scrollbarReserve; + boolean clipped = ScissorFunction.pushRaw(contentX - 1f, contentY - 1f, contentW + 2f, contentH + 2f); + + float cy = contentY - detailScroll; + for (Setting setting : editor.settings) { + float rowH = setting.getHeight(); + if (cy + rowH >= contentY - 2f && cy <= contentY + contentH + 2f) { + setting.render(contentX, cy, contentW, mx, my); + ui.settingHits.add(new SettingHit(setting, contentX, cy, contentW, rowH)); + } + cy += rowH + 2f; + } + + if (clipped) ScissorFunction.pop(); + drawDetailScrollbar(x, contentY, w, contentH); + } + + private void drawItemHeader(float x, + float y, + float w, + String itemId, + String name, + ItemStack stack, + boolean hasRule, + float mx, + float my) { + TextRenderer titleFont = UnifiedSettingsSkin.fontSemibold(); + TextRenderer font = UnifiedSettingsSkin.fontRegular(); + float small = 11.5f; + + float iconBox = 46f; + float iconX = x + PANEL_PAD; + float iconY = y + 50f; + ClickGuiRenderer.drawRoundedRect(iconX, iconY, iconBox, iconBox, 7f, UnifiedSettingsSkin.SURFACE); + ClickGuiRenderer.drawRoundedRectStroke(iconX, iconY, iconBox, iconBox, 7f, 0.75f, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 75)); + if (!stack.isEmpty()) { + float scale = 1.85f; + float px = 16f * scale; + ClickGuiRenderer.queuePickerIcon(stack, iconX + (iconBox - px) * 0.5f, iconY + (iconBox - px) * 0.5f, scale, iconX, iconY, iconBox, iconBox); + } + + float textX = iconX + iconBox + 11f; + float buttonW = hasRule ? 76f : 68f; + float buttonH = 22f; + float buttonX = x + w - PANEL_PAD - buttonW; + float buttonY = y + 62f; + ClickGuiRenderer.drawText(titleFont, fit(titleFont, name, 15f, buttonX - textX - 10f), textX, y + 53f, 15f, UnifiedSettingsSkin.TEXT_PRIMARY, false); + ClickGuiRenderer.drawText(font, fit(font, itemId, small, buttonX - textX - 10f), textX, y + 73f, small, UnifiedSettingsSkin.TEXT_MUTED, false); + + Action action = hasRule ? Action.REMOVE_RULE : Action.ADD_RULE; + String label = hasRule ? "Remove" : "Add"; + chip(buttonX, buttonY, buttonW, buttonH, label, inside(mx, my, buttonX, buttonY, buttonW, buttonH), hasRule); + ui.actionHits.add(new ActionHit(action, itemId, buttonX, buttonY, buttonW, buttonH)); + } + + private void drawEmptyDetails(float x, float y, float w, float h) { + TextRenderer font = UnifiedSettingsSkin.fontRegular(); + String line1 = "Select an item on the left."; + String line2 = "A grid click only focuses it; rule creation is explicit."; + float size = 12f; + float lineH = ClickGuiRenderer.textHeight(font, size) + 4f; + float top = y + h * 0.5f - lineH; + ClickGuiRenderer.drawText(font, line1, x + PANEL_PAD, top, size, UnifiedSettingsSkin.TEXT_MUTED, false); + ClickGuiRenderer.drawText(font, fit(font, line2, size, w - PANEL_PAD * 2f), x + PANEL_PAD, top + lineH, size, UnifiedSettingsSkin.TEXT_MUTED, false); + } + + private void drawMissingRuleHint(float x, float y, float w, float h) { + TextRenderer font = UnifiedSettingsSkin.fontRegular(); + String line1 = "No cooldown rule for this item."; + String line2 = "Press Add to create one, then configure it below."; + float size = 12f; + float lineH = ClickGuiRenderer.textHeight(font, size) + 4f; + float top = y + HEADER_H + 14f; + ClickGuiRenderer.drawText(font, line1, x + PANEL_PAD, top, size, UnifiedSettingsSkin.TEXT_MUTED, false); + ClickGuiRenderer.drawText(font, fit(font, line2, size, w - PANEL_PAD * 2f), x + PANEL_PAD, top + lineH, size, UnifiedSettingsSkin.TEXT_MUTED, false); + } + + private void chip(float x, float y, float w, float h, String text, boolean hover, boolean destructive) { + int bg = destructive ? UnifiedSettingsSkin.withAlpha(0xFFFF5555, 32) : UnifiedSettingsSkin.ACCENT_SOFT; + int stroke = destructive ? UnifiedSettingsSkin.withAlpha(0xFFFF7777, 120) : UnifiedSettingsSkin.ACCENT; + if (hover) bg = UnifiedSettingsSkin.mix(bg, UnifiedSettingsSkin.SURFACE_HOVER, 0.45f); + ClickGuiRenderer.drawRoundedRect(x, y, w, h, 4f, bg); + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, 4f, 0.6f, stroke); + TextRenderer font = UnifiedSettingsSkin.fontRegular(); + float size = 10.5f; + float tw = ClickGuiRenderer.textWidth(font, text, size); + float th = ClickGuiRenderer.textHeight(font, size); + ClickGuiRenderer.drawText(font, text, x + (w - tw) * 0.5f, y + (h - th) * 0.5f, size, UnifiedSettingsSkin.TEXT_PRIMARY, false); + } + + private void drawDetailScrollbar(float panelX, float contentY, float panelW, float contentH) { + if (detailMaxScroll <= 0.5f) return; + + float trackX = panelX + panelW - PANEL_PAD - DETAIL_SCROLLBAR_W; + float trackY = contentY + 1f; + float trackH = Math.max(1f, contentH - 2f); + ClickGuiRenderer.drawRoundedRect( + trackX, + trackY, + DETAIL_SCROLLBAR_W, + trackH, + DETAIL_SCROLLBAR_W * 0.5f, + UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 38) + ); + + float ratio = contentH / (contentH + detailMaxScroll); + float thumbH = Math.max(18f, trackH * ratio); + float thumbRange = Math.max(1f, trackH - thumbH); + float thumbY = trackY + thumbRange * (detailScroll / Math.max(1f, detailMaxScroll)); + ClickGuiRenderer.drawRoundedRect( + trackX, + thumbY, + DETAIL_SCROLLBAR_W, + thumbH, + DETAIL_SCROLLBAR_W * 0.5f, + UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 120) + ); + } + + private void clampDetailScroll() { + detailScroll = Math.max(0f, Math.min(detailScroll, detailMaxScroll)); + } + + private void handleAction(Action action, String itemId) { + if (rules == null || itemId == null || itemId.isBlank()) return; + switch (action) { + case ADD_RULE -> { + rules.ensureRule(itemId); + focusedItemId = itemId; + detailScroll = 0f; + editorFor(itemId).syncFromRule(); + syncBridge(); + saveParentConfig(); + ClickGuiSounds.changeMode(); + } + case REMOVE_RULE -> { + rules.removeRule(itemId); + editors.remove(itemId); + focusedItemId = itemId; + detailScroll = 0f; + syncBridge(); + saveParentConfig(); + ClickGuiSounds.changeMode(); + } + } + } + + private RuleEditor editorFor(String itemId) { + return editors.computeIfAbsent(itemId, RuleEditor::new); + } + + private String resolveFocusedId(String focusedId) { + String normalized = normalizeEntry(focusedId); + if (normalized != null && !normalized.isBlank()) { + focusedItemId = normalized; + return normalized; + } + if (focusedItemId != null && !focusedItemId.isBlank()) return focusedItemId; + Set ids = getValueSet(); + if (ids.isEmpty()) return null; + focusedItemId = ids.iterator().next(); + return focusedItemId; + } + + private void syncBridge() { + ConfigValue bridge = super.getConfigValue(); + if (bridge instanceof SetValue set) { + set.set(getValueSet()); + } + } + + private void saveParentConfig() { + if (getParent() != null) getParent().saveConfig(); + } + + private enum Action { + ADD_RULE, + REMOVE_RULE + } + + private record ActionHit(Action action, String itemId, float x, float y, float w, float h) { + } + + private record SettingHit(Setting setting, float x, float y, float w, float h) { + } + + private static final class RuleBooleanSetting extends BooleanSetting { + private final RuleEditor editor; + + RuleBooleanSetting(String name, BooleanValue value, RuleEditor editor) { + super(name, value); + this.editor = editor; + } + + @Override + public void mouseClicked(double mx, double my, int button) { + boolean before = get(); + super.mouseClicked(mx, my, button); + if (before != get()) editor.commit(); + } + } + + private static final class RuleSliderSetting extends SliderSetting { + private final RuleEditor editor; + + RuleSliderSetting(String name, NumberValue value, RuleEditor editor) { + super(name, value); + this.editor = editor; + } + + @Override + public void mouseClicked(double mx, double my, int button) { + int before = value().get(); + super.mouseClicked(mx, my, button); + if (before != value().get()) editor.commit(); + } + + @Override + public void mouseReleased(double mx, double my, int button) { + boolean wasDragging = ui().dragging; + int before = value().get(); + super.mouseReleased(mx, my, button); + if (wasDragging || before != value().get()) editor.commit(); + } + } + + private static final class Ui { + final List actionHits = new ArrayList<>(); + final List settingHits = new ArrayList<>(); + } + + private final class RuleEditor { + final String itemId; + final BooleanValue enabledValue = new BooleanValue("cooldown_rule_enabled", false); + final NumberValue secondsValue = new NumberValue<>("cooldown_rule_seconds", 0, 0, 600); + final NumberValue usesValue = new NumberValue<>("cooldown_rule_uses", 1, 1, 10); + final NumberValue windowValue = new NumberValue<>("cooldown_rule_window", 0, 0, 300); + final ModeValue scopeValue = new ModeValue("cooldown_rule_scope", ItemCooldownRulesValue.Scope.PVP_GRACE.getId(), "always", "pvp_only", "pvp_grace"); + final ModeValue triggerValue = new ModeValue("cooldown_rule_trigger", ItemCooldownRulesValue.Trigger.CONSUME_FINISH.getId(), "interact_accept", "consume_finish", "totem_pop"); + final ModeValue blockValue = new ModeValue("cooldown_rule_block", ItemCooldownRulesValue.UseBlockMode.NONE.getId(), "none", "cooldown", "window", "any"); + + final List settings = new ArrayList<>(); + + final RuleBooleanSetting enabledSetting; + final RuleSliderSetting secondsSetting; + final RuleSliderSetting usesSetting; + final RuleSliderSetting windowSetting; + final ModeSetting scopeSetting; + final ModeSetting triggerSetting; + final ModeSetting blockSetting; + + RuleEditor(String itemId) { + this.itemId = itemId; + enabledSetting = new RuleBooleanSetting("Enabled", enabledValue, this); + secondsSetting = new RuleSliderSetting("Time (s)", secondsValue, this); + usesSetting = new RuleSliderSetting("Uses", usesValue, this); + windowSetting = new RuleSliderSetting("Window (s)", windowValue, this); + scopeSetting = new ModeSetting("Scope", scopeValue, this::commit); + triggerSetting = new ModeSetting("Trigger", triggerValue, this::commit); + blockSetting = new ModeSetting("Block", blockValue, this::commit); + + settings.add(enabledSetting); + settings.add(secondsSetting); + settings.add(usesSetting); + settings.add(windowSetting); + settings.add(scopeSetting); + settings.add(triggerSetting); + settings.add(blockSetting); + } + + void syncFromRule() { + if (rules == null) return; + ItemCooldownRulesValue.Rule rule = rules.getRule(itemId); + if (rule == null) return; + enabledValue.set(rule.enabled()); + secondsValue.set(rule.seconds()); + usesValue.set(rule.uses()); + windowValue.set(rule.windowSeconds()); + scopeValue.set(rule.scope().getId()); + triggerValue.set(rule.trigger().getId()); + blockValue.set(rule.blockMode().getId()); + } + + boolean isDraggingSlider() { + return secondsSetting.ui().dragging || usesSetting.ui().dragging || windowSetting.ui().dragging; + } + + void commit() { + if (rules == null || !rules.containsRule(itemId)) return; + ItemCooldownRulesValue.Rule current = rules.getRule(itemId); + if (current == null) current = rules.getDefaultRule(itemId); + + ItemCooldownRulesValue.Rule next = new ItemCooldownRulesValue.Rule( + enabledValue.get(), + secondsValue.get(), + usesValue.get(), + windowValue.get(), + scopeById(scopeValue.get()), + triggerById(triggerValue.get()), + blockById(blockValue.get()) + ).normalized(); + + rules.putRule(itemId, next); + syncFromRule(); + syncBridge(); + saveParentConfig(); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/FunctionBindSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/FunctionBindSetting.java new file mode 100644 index 0000000..0b815c3 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/FunctionBindSetting.java @@ -0,0 +1,232 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.config.values.BindMode; +import dev.loki.lovisual.config.values.KeyBindValue; +import dev.loki.lovisual.util.input.KeyManager; +import dev.loki.lovisual.util.wav.ClickGuiSounds; + +import java.util.function.BooleanSupplier; + +/** + * Function bind without toggle(): listens to active state (PRESS/HOLD). + */ +public class FunctionBindSetting extends Setting { + + private final KeyBindValue value; + private final BindMode mode; + private final UiState ui = new UiState(); + private String registeredName = null; + private String hudIcon = null; + private String hudLabel = null; + private BooleanSupplier hudToggleState = null; + + public FunctionBindSetting(String id, String defaultKey) { + this(id, defaultKey, BindMode.PRESS); + } + + public FunctionBindSetting(String id, String defaultKey, BindMode mode) { + this(id, new KeyBindValue(id, defaultKey), mode); + } + + public FunctionBindSetting(String id, KeyBindValue value, BindMode mode) { + super("Function: " + id, value); + this.value = value; + this.mode = mode; + } + + public String get() { + return value.get(); + } + + public void set(String keyName) { + value.set(keyName.toUpperCase()); + } + + public boolean isPressed() { + return KeyManager.wasPressed(bindingName()); + } + + BindMode mode() { + return mode; + } + + public BindMode getMode() { + return mode; + } + + public String getActionId() { + return value != null ? value.getName() : getId(); + } + + public String getHudLabel() { + return hudLabel; + } + + public String getHudIcon() { + return hudIcon; + } + + public FunctionBindSetting hudIcon(String icon) { + this.hudIcon = icon == null || icon.isBlank() ? null : icon.trim(); + return this; + } + + public FunctionBindSetting hudSvg(String svgName) { + return hudIcon(svgName); + } + + public FunctionBindSetting hudLabel(String label) { + this.hudLabel = label == null || label.isBlank() ? null : label.trim(); + return this; + } + + public FunctionBindSetting hud(String label, String icon) { + return hudLabel(label).hudSvg(icon); + } + + public FunctionBindSetting hudToggle(BooleanSupplier activeState) { + this.hudToggleState = activeState; + return this; + } + + public boolean isHudToggle() { + return hudToggleState != null; + } + + public boolean isHeldForHud() { + return KeyManager.isHeldAllowScreen(bindingName()); + } + + public boolean isHudToggleActive() { + if (hudToggleState == null) return false; + try { + return hudToggleState.getAsBoolean(); + } catch (Throwable ignored) { + return false; + } + } + + UiState ui() { + return ui; + } + + // ====================== CONFIG ====================== + + @Override + public Object save() { + return value.toJson(); + } + + @Override + public void load(Object o) { + value.fromJson(o); + refreshRegistration(); + } + + // ======================== UI ======================== + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int b) { + SettingRendererBridge.mouseClicked(this, mx, my, b); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + public void onKeySelected(String combo) { + String previous = value.get(); + boolean wasBound = previous != null && !previous.equalsIgnoreCase("NONE"); + if (combo == null || combo.equalsIgnoreCase("NONE")) { + set("NONE"); + ClickGuiSounds.bindingNull(); + } else { + set(combo); + if (wasBound) { + ClickGuiSounds.bindingReset(); + } else { + ClickGuiSounds.bindingSuccess(); + } + } + refreshRegistration(); + ui.waiting = false; + if (getParent() != null) getParent().saveConfig(); + } + + public String getBindingName() { + return bindingName(); + } + + @Override + public void setParent(SettingOwner owner) { + // unregister under previous name (could be null before parent is set) + unregister(); + super.setParent(owner); + refreshRegistration(); + } + + private String bindingName() { + SettingOwner parent = getParent(); + String actionId = value.getName(); + return parent != null ? parent.name() + ":" + actionId : actionId; + } + + @Override + protected String getTranslationId() { + return value != null ? value.getName() : super.getTranslationId(); + } + + private void refreshRegistration() { + unregister(); + if (!value.isNone()) { + registeredName = bindingName(); + KeyManager.registerCombo(registeredName, value.get()); + } + } + + private void unregister() { + if (registeredName != null) { + KeyManager.unregisterAll(registeredName); + } else { + // In case it was registered before parent was set, try using current binding name + KeyManager.unregisterAll(bindingName()); + } + registeredName = null; + } + + static final class UiState { + float lastX; + float lastY; + boolean waiting = false; + float lastW; + float lastH; + float lastBx, lastBy, lastBw, lastBh; + float hoverAnim = 0f; + float waitAnim = 0f; + float layoutW = Float.NaN; + String layoutLabel = null; + String layoutDisplay = null; + String[] layoutLines = new String[]{""}; + float layoutLineH = 0f; + float layoutLabelY = 0f; + float layoutMarkerY = 0f; + float layoutBoxY = 0f; + float layoutRowH = 54f; + float layoutBoxW = 36f; + float layoutTextW = 0f; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/GroupSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/GroupSetting.java new file mode 100644 index 0000000..aad12f5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/GroupSetting.java @@ -0,0 +1,100 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.BooleanMapValue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public class GroupSetting extends Setting { + + private final BooleanMapValue value; + private final UiState ui = new UiState(); + private Predicate optionVisibility = key -> true; + + public GroupSetting(String name, BooleanMapValue value) { + super(name, value); // <--- FIX + this.value = value; + } + + BooleanMapValue value() { + return value; + } + + UiState ui() { + return ui; + } + + boolean isOptionVisible(String key) { + return optionVisibility.test(key); + } + + public GroupSetting visibleOptionsWhen(Predicate predicate) { + this.optionVisibility = predicate != null ? predicate : key -> true; + ui.lastLayoutW = Float.NaN; + ui.layoutOptionCount = -1; + return this; + } + + @Override + public void preflightI18n() { + super.preflightI18n(); + if (value == null) return; + for (String key : value.getAll().keySet()) { + if (!isOptionVisible(key)) continue; + preflightOptionI18n(key); + } + } + + @Override + public Object save() { + return value == null ? null : value.toJson(); + } + + @Override + public void load(Object o) { + if (value != null) value.fromJson(o); + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int b) { + SettingRendererBridge.mouseClicked(this, mx, my, b); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + static final class UiState { + final List optionLayouts = new ArrayList<>(); + final Map hoverAnims = new HashMap<>(); + final Map selectAnims = new HashMap<>(); + float baseX, baseY, baseW; + float lastLayoutW = Float.NaN; + int layoutOptionCount = -1; + float cachedHeight = 34f; + } + + record OptionLayout(String id, String label, float x, float y, float w) { + public String getId() { + return id; + } + } +} + + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nDuplicateScanner.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nDuplicateScanner.java new file mode 100644 index 0000000..daaf789 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nDuplicateScanner.java @@ -0,0 +1,125 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.util.logging.DebugLog; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public enum I18nDuplicateScanner { + ; + + private static final Pattern KEY_PATTERN = Pattern.compile("^\\s*\"([^\"]+)\"\\s*:"); + + public static void scan(ResourceManager manager, String reason) { + if (manager == null) return; + + Map> resources = manager.listResourceStacks( + "lang", + id -> "combatant".equals(id.getNamespace()) && id.getPath().endsWith(".json") + ); + if (resources.isEmpty()) return; + + StringBuilder report = new StringBuilder(); + int filesWithProblems = 0; + int duplicateCount = 0; + + for (Map.Entry> entry : resources.entrySet()) { + Identifier id = entry.getKey(); + List stack = entry.getValue(); + if (id == null || stack == null || stack.isEmpty()) continue; + + StringBuilder fileReport = new StringBuilder(); + for (Resource resource : stack) { + ScanResult result = scanResource(resource); + if (!result.hasProblems()) continue; + duplicateCount += result.problemCount(); + fileReport.append(" - ").append(resource.sourcePackId()).append('\n'); + for (String line : result.lines()) { + fileReport.append(" - ").append(line).append('\n'); + } + } + + if (!fileReport.isEmpty()) { + filesWithProblems++; + report.append("- ").append(id).append('\n').append(fileReport); + } + } + + if (filesWithProblems > 0) { + DebugLog.warn( + "I18n duplicate keys (reason=%s, files=%d, duplicates=%d):%n%s", + reason, + filesWithProblems, + duplicateCount, + report + ); + } + } + + private static ScanResult scanResource(Resource resource) { + LinkedHashMap exact = new LinkedHashMap<>(); + LinkedHashMap caseFolded = new LinkedHashMap<>(); + ArrayList lines = new ArrayList<>(); + if (resource == null) return new ScanResult(lines); + + try (Reader reader = resource.openAsReader(); + BufferedReader buffered = new BufferedReader(reader)) { + String line; + int lineNo = 0; + while ((line = buffered.readLine()) != null) { + lineNo++; + Matcher matcher = KEY_PATTERN.matcher(line); + if (!matcher.find()) continue; + + String key = matcher.group(1); + Occurrence previousExact = exact.putIfAbsent(key, new Occurrence(key, lineNo)); + if (previousExact != null) { + lines.add("exact line " + lineNo + " duplicates line " + previousExact.line() + ": " + key); + continue; + } + + String folded = key.toLowerCase(Locale.ROOT); + Occurrence previousFolded = caseFolded.putIfAbsent(folded, new Occurrence(key, lineNo)); + if (previousFolded != null && !previousFolded.key().equals(key)) { + lines.add("case line " + lineNo + " duplicates line " + previousFolded.line() + + ": " + previousFolded.key() + " / " + key); + } + } + } catch (IOException e) { + lines.add("scan failed: " + e.getMessage()); + } + + return new ScanResult(lines); + } + + private record Occurrence(String key, int line) { + } + + private record ScanResult(List lines) { + boolean hasProblems() { + return !lines.isEmpty(); + } + + int problemCount() { + return lines.size(); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightContributors.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightContributors.java new file mode 100644 index 0000000..779d858 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightContributors.java @@ -0,0 +1,133 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.ConfigSerializer; +import dev.loki.lovisual.config.MainConfig; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.events.EventHandler; +import dev.loki.lovisual.events.impl.I18nPreflightCollectEvent; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.features.module.modules.visuals.DropESP; +import dev.loki.lovisual.features.module.modules.visuals.NameTags; +import dev.loki.lovisual.features.relations.PlayerRelations; +import dev.loki.lovisual.util.item.IllegalItemUtil; +import dev.loki.lovisual.util.item.RarityColorConfig; +import dev.loki.lovisual.util.item.TopEnchantUtil; + +import java.util.ArrayList; +import java.util.List; + +public enum I18nPreflightContributors { + INSTANCE; + + private static final SettingOwner RELATIONS_OWNER = + new StaticOwner("relations_color", () -> PlayerRelations.get().save()); + private static final SettingOwner ITEMS_OWNER = + new StaticOwner("items_color", I18nPreflightContributors::saveItemColorOwners); + + @EventHandler(priority = 1000) + private void onCollect(I18nPreflightCollectEvent event) { + collectModules(event); + collectHud(event); + collectMainConfig(event); + collectStaticSettings(event); + } + + private static void collectModules(I18nPreflightCollectEvent event) { + for (Module module : ModuleManager.getModules()) { + if (module == null) continue; + event.settings("modules/" + module.name(), module.getSettings()); + } + } + + private static void collectHud(I18nPreflightCollectEvent event) { + event.settingDefs("hud/global", HudGlobalConfig.get(), HudGlobalConfig.get().getSettingDefs()); + + for (DraggableHudElement widget : DraggableHudElementRegistry.getWidgets()) { + if (widget == null) continue; + event.settingDefs("hud/draggable/" + widget.getId(), widget, widget.getSettingDefs()); + } + + for (AbstractHudElement element : StaticHudElementRegistry.getAll()) { + if (element == null) continue; + event.settingDefs("hud/static/" + element.getId(), element, element.getSettingDefs()); + } + } + + private static void collectMainConfig(I18nPreflightCollectEvent event) { + MainConfig config = MainConfig.get(); + event.settingDefs("main_config/image", config, config.getImageSettingDefs()); + event.settingDefs("main_config/misc", config, config.getMiscellaneousSettingDefs()); + event.settingDefs("main_config/security", config, config.getSecuritySettingDefs()); + event.settingDefs("main_config/utility", config, config.getUtilitySettingDefs()); + } + + private static void collectStaticSettings(I18nPreflightCollectEvent event) { + event.settingDefs("settings/relations_color", RELATIONS_OWNER, relationColorDefs()); + event.settingDefs("settings/items_color", ITEMS_OWNER, itemColorDefs()); + } + + private static List relationColorDefs() { + PlayerRelations relations = PlayerRelations.get(); + List defs = new ArrayList<>(); + defs.add(SettingDef.colorNoAlpha("default_color", relations.colorDefaultValue())); + defs.add(SettingDef.colorNoAlpha("friend_color", relations.colorFriendValue())); + defs.add(SettingDef.colorNoAlpha("enemy_color", relations.colorEnemyValue())); + defs.add(SettingDef.colorNoAlpha("staff_color", relations.colorStaffValue())); + defs.add(SettingDef.colorNoAlpha("bedwars_self_color", relations.colorBedwarsSelfValue())); + defs.add(SettingDef.colorNoAlpha("bedwars_enemy_color", relations.colorBedwarsEnemyValue())); + return defs; + } + + private static List itemColorDefs() { + RarityColorConfig rarity = RarityColorConfig.INSTANCE; + List defs = new ArrayList<>(); + defs.add(SettingDef.colorNoAlpha("illegal_item_color", IllegalItemUtil.illegalColorValue())); + defs.add(SettingDef.colorNoAlpha("top_enchant_color", TopEnchantUtil.topColorValue())); + defs.add(SettingDef.colorNoAlpha("rarity_common", rarity.commonValue())); + defs.add(SettingDef.colorNoAlpha("rarity_uncommon", rarity.uncommonValue())); + defs.add(SettingDef.colorNoAlpha("rarity_rare", rarity.rareValue())); + defs.add(SettingDef.colorNoAlpha("rarity_epic", rarity.epicValue())); + return defs; + } + + private static void saveItemColorOwners() { + ConfigSerializer.requestSave(RarityColorConfig.INSTANCE); + BetterChat chat = BetterChat.get(); + if (chat != null) { + chat.saveConfig(); + } + DropESP dropESP = Modules.get(DropESP.class); + if (dropESP != null) { + dropESP.saveConfig(); + } + NameTags nameTags = Modules.get(NameTags.class); + if (nameTags != null) { + nameTags.saveConfig(); + } + } + + private record StaticOwner(String name, Runnable saveAction) implements SettingOwner { + @Override + public void saveConfig() { + if (saveAction != null) { + saveAction.run(); + } + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightManager.java new file mode 100644 index 0000000..127a29f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/I18nPreflightManager.java @@ -0,0 +1,130 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.events.Events; +import dev.loki.lovisual.events.impl.I18nPreflightCollectEvent; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +public enum I18nPreflightManager { + ; + + public static void preflight(String reason) { + if (!Setting.prepareI18nPreflightPass()) { + DebugLog.warnOnChange( + "i18n.preflight.language.not.ready", + reason, + "I18n preflight skipped because language resources are not ready. reason=%s", + reason + ); + return; + } + + I18nPreflightCollectEvent event = Events.BUS.post(new I18nPreflightCollectEvent(reason)); + ReportNode root = new ReportNode("root"); + int checked = 0; + for (I18nPreflightCollectEvent.Entry entry : event.entries()) { + if (entry == null || entry.setting() == null) continue; + Setting setting = entry.setting(); + ReportNode node = nodeFor(root, entry.path()).child(setting.getId()); + try (AutoCloseable ignored = Setting.captureMissingI18n(node::missing)) { + setting.preflightI18n(); + } catch (Exception e) { + DebugLog.warn("I18n preflight failed for %s/%s: %s", entry.path(), setting.getId(), e.getMessage()); + } + checked++; + node.pruneIfClean(); + } + + if (root.hasProblems()) { + DebugLog.warn("I18n preflight missing keys (reason=%s, settings=%d):%n%s", reason, checked, root.render()); + } else { + DebugLog.config("I18n preflight complete: reason=%s settings=%d missing=0", reason, checked); + } + } + + private static ReportNode nodeFor(ReportNode root, String path) { + ReportNode node = root; + if (path == null || path.isBlank()) return node.child("settings"); + for (String part : path.split("/")) { + if (part == null || part.isBlank()) continue; + node = node.child(part); + } + return node; + } + + private static final class ReportNode { + private final String label; + private final LinkedHashMap children = new LinkedHashMap<>(); + private final LinkedHashSet missing = new LinkedHashSet<>(); + + private ReportNode(String label) { + this.label = label == null || label.isBlank() ? "" : label; + } + + private ReportNode child(String label) { + String safe = label == null || label.isBlank() ? "" : label; + return children.computeIfAbsent(safe, ReportNode::new); + } + + private void missing(List keyChain) { + if (keyChain == null || keyChain.isEmpty()) return; + ArrayList cleaned = new ArrayList<>(keyChain.size()); + for (String key : keyChain) { + if (key != null && !key.isBlank()) cleaned.add(key); + } + if (!cleaned.isEmpty()) { + missing.add(String.join(" -> ", cleaned)); + } + } + + private boolean hasProblems() { + if (!missing.isEmpty()) return true; + for (ReportNode child : children.values()) { + if (child.hasProblems()) return true; + } + return false; + } + + private void pruneIfClean() { + children.entrySet().removeIf(entry -> !entry.getValue().hasProblems()); + } + + private String render() { + StringBuilder out = new StringBuilder(); + for (ReportNode child : children.values()) { + child.render(out, 0); + } + return out.toString(); + } + + private void render(StringBuilder out, int depth) { + if (!hasProblems()) return; + indent(out, depth).append("- ").append(label).append('\n'); + for (String chain : missing) { + indent(out, depth + 1).append("- ").append(chain).append('\n'); + } + for (Map.Entry entry : children.entrySet()) { + entry.getValue().render(out, depth + 1); + } + } + + private static StringBuilder indent(StringBuilder out, int depth) { + for (int i = 0; i < depth; i++) { + out.append(" "); + } + return out; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/KeyBindSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/KeyBindSetting.java new file mode 100644 index 0000000..6110664 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/KeyBindSetting.java @@ -0,0 +1,140 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.config.values.KeyBindValue; +import dev.loki.lovisual.util.input.KeyManager; +import dev.loki.lovisual.util.wav.ClickGuiSounds; + +public class KeyBindSetting extends Setting { + + private final KeyBindValue value; + private final UiState ui = new UiState(); + + public KeyBindSetting(KeyBindValue value) { + super(value.getName(), value); + this.value = value; + + if (!value.isNone()) { + KeyManager.registerCombo(bindingName(), value.get()); + } + } + + public KeyBindValue getValue() { + return value; + } + + UiState ui() { + return ui; + } + + private String bindingName() { + return getParent() != null ? getParent().name() : value.getName(); + } + + @Override + public void setParent(SettingOwner owner) { + super.setParent(owner); + // re-register under module name once parent is known + KeyManager.unregisterAll(value.getName()); + if (!value.isNone()) { + KeyManager.registerCombo(bindingName(), value.get()); + } + } + + public void setKeyByName(String name) { + value.set(name); + KeyManager.unregisterAll(bindingName()); + if (!value.isNone()) { + KeyManager.registerCombo(bindingName(), name); + } + } + + @Override + public String getTranslationKey() { + return "setting.module.bind"; + } + // ========================== CONFIG ============================ // + + @Override + public Object save() { + return value.toJson(); + } + + @Override + public void load(Object o) { + value.fromJson(o); + + KeyManager.unregisterAll(bindingName()); + + if (!value.isNone()) + KeyManager.registerCombo(bindingName(), value.get()); + } + + // =============================== UI ================================ // + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int b) { + SettingRendererBridge.mouseClicked(this, mx, my, b); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + public void onKeySelected(String comboString) { + String previous = value.get(); + boolean wasBound = previous != null && !previous.equalsIgnoreCase("NONE"); + KeyManager.unregisterAll(bindingName()); + + if (comboString == null || comboString.equalsIgnoreCase("NONE")) { + value.set("NONE"); + ClickGuiSounds.bindingNull(); + } else { + value.set(comboString.toUpperCase()); + KeyManager.registerCombo(bindingName(), comboString); + if (wasBound) { + ClickGuiSounds.bindingReset(); + } else { + ClickGuiSounds.bindingSuccess(); + } + } + + ui.waiting = false; + if (getParent() != null) getParent().saveConfig(); + } + + static final class UiState { + float lastX; + float lastY; + boolean waiting = false; + float lastW; + float lastH; + float lastBx, lastBy, lastBw, lastBh; + float hoverAnim = 0f; + float waitAnim = 0f; + float layoutW = Float.NaN; + String layoutLabel = null; + String layoutDisplay = null; + String[] layoutLines = new String[]{""}; + float layoutLineH = 0f; + float layoutLabelY = 0f; + float layoutBoxY = 0f; + float layoutRowH = 46f; + float layoutBoxW = 36f; + float layoutTextW = 0f; + } +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModeSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModeSetting.java new file mode 100644 index 0000000..d7fd761 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModeSetting.java @@ -0,0 +1,126 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.config.values.ModeValue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ModeSetting extends Setting { + + private final ModeValue modeValue; + private final EnumValue enumValue; + private final Runnable onChange; + private final UiState ui = new UiState(); + + public ModeSetting(String name, ModeValue value) { + this(name, value, null); + } + + public ModeSetting(String name, ModeValue value, Runnable onChange) { + super(name, value); + this.modeValue = value; + this.enumValue = null; + this.onChange = onChange; + } + + public ModeSetting(String name, EnumValue value) { + this(name, value, null); + } + + public ModeSetting(String name, EnumValue value, Runnable onChange) { + super(name, value); + this.modeValue = null; + this.enumValue = value; + this.onChange = onChange; + } + + Runnable onChange() { + return onChange; + } + + UiState ui() { + return ui; + } + + List optionIds() { + return modeValue != null ? modeValue.getOptions() : enumValue.getOptions(); + } + + String selectedId() { + return modeValue != null ? modeValue.get() : enumValue.getId(); + } + + void selectId(String id) { + if (modeValue != null) { + modeValue.fromJson(id); + } else { + enumValue.setById(id); + } + } + + @Override + public void preflightI18n() { + super.preflightI18n(); + for (String opt : optionIds()) { + preflightOptionI18n(opt); + } + } + + @Override + public Object save() { + if (modeValue != null) return modeValue.toJson(); + return enumValue == null ? null : enumValue.toJson(); + } + + @Override + public void load(Object o) { + if (modeValue != null) { + modeValue.fromJson(o); + } else if (enumValue != null) { + enumValue.fromJson(o); + } + if (onChange != null) onChange.run(); + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int b) { + SettingRendererBridge.mouseClicked(this, mx, my, b); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + static final class UiState { + final List optionLayouts = new ArrayList<>(); + final Map hoverAnims = new HashMap<>(); + float baseX, baseY, baseW; + float cachedHeight = 64f; + float selectX, selectY, selectW; + boolean selectInit = false; + float lastLayoutW = Float.NaN; + int layoutOptionCount = -1; + } + + record OptionLayout(String id, String label, float x, float y, float w) { + public String getId() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModulesSettingsSkin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModulesSettingsSkin.java new file mode 100644 index 0000000..f7a9920 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ModulesSettingsSkin.java @@ -0,0 +1,145 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +enum ModulesSettingsSkin { + ; + static final float HEADER_H = 19f; + + static int TEXT_PRIMARY = 0xFFE8ECF7; + static int TEXT_MUTED = 0xFFADB5C6; + + static int SURFACE = 0x301A1F27; + static int SURFACE_HOVER = 0x40222932; + static int SURFACE_SOFT = 0x28161B23; + static int STROKE = 0x46FFFFFF; + + static int ACCENT = 0xFF9747FF; + static int ACCENT_SOFT = 0x629747FF; + static int CHECK_COLOR = 0xFFEFF3FF; + + static void syncTheme() { + Themes.Theme theme = Theme.theme(); + if (theme == null) return; + + TEXT_PRIMARY = forceAlpha(theme.textPrimary(), 255); + TEXT_MUTED = withAlpha(theme.textMuted(), 210); + + ACCENT = forceAlpha(theme.accent(), 255); + ACCENT_SOFT = withAlpha(theme.accentSoft(), 135); + CHECK_COLOR = ClickGuiRenderer.mixColor(TEXT_PRIMARY, ACCENT, 0.10f); + + int surface = forceAlpha(theme.surface(), 255); + int hover = forceAlpha(theme.surfaceHover(), 255); + int accentTint = forceAlpha(theme.accent(), 255); + + SURFACE = withAlpha(ClickGuiRenderer.mixColor(surface, accentTint, 0.075f), 86); + SURFACE_HOVER = withAlpha(ClickGuiRenderer.mixColor(hover, accentTint, 0.13f), 112); + SURFACE_SOFT = withAlpha(ClickGuiRenderer.mixColor(surface, accentTint, 0.055f), 62); + STROKE = withAlpha(ClickGuiRenderer.mixColor(theme.strokeSoft(), accentTint, 0.16f), 118); + } + + static float scale() { + syncTheme(); + return Math.max(0.25f, Math.min(4.0f, SettingRenderContext.scale())); + } + + static float s(float value) { + return value * scale(); + } + + static TextRenderer fontRegular() { + return Fonts.renderer("Onest", FontInfo.Type.Regular); + } + + static TextRenderer fontMedium() { + return Fonts.renderer("OnestMedium", FontInfo.Type.Regular); + } + + static TextRenderer fontSemibold() { + return Fonts.renderer("OnestBold", FontInfo.Type.Regular); + } + + static TextRenderer fontLight() { + return Fonts.renderer("OnestLight", FontInfo.Type.Regular); + } + + static boolean inside(double mx, double my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + static int mix(int from, int to, float t) { + t = clamp01(t); + int a = (int) (((from >>> 24) & 0xFF) * (1f - t) + ((to >>> 24) & 0xFF) * t); + int r = (int) (((from >>> 16) & 0xFF) * (1f - t) + ((to >>> 16) & 0xFF) * t); + int g = (int) (((from >>> 8) & 0xFF) * (1f - t) + ((to >>> 8) & 0xFF) * t); + int b = (int) (((from) & 0xFF) * (1f - t) + ((to) & 0xFF) * t); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + static int withAlpha(int color, float alpha01) { + int base = (color >>> 24) & 0xFF; + int a = Math.round(base * clamp01(alpha01)); + return (color & 0x00FFFFFF) | (a << 24); + } + + static int withAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + static int forceAlpha(int color, int alpha) { + return withAlpha(color, alpha); + } + + static void drawGlass(float x, float y, float w, float h, float radius, float alpha) { + if (alpha <= 0.001f) return; + float a = clamp01(alpha); + Renderer2D.COLOR.liquidGlassRect( + x, y, w, h, + radius, + Math.max(3.0f * scale(), radius * 0.65f), + Theme.theme().accent(), + a * 0.58f, + -50.0f, + 0.95f, + 0.58f, + 0.10f, + 0.055f * a, + 0.0f + ); + } + + static String fit(TextRenderer font, String text, float size, float width) { + return ClickGuiRenderer.fitText(font, text, size, Math.max(1f, width)); + } + + static void drawCheckIcon(float x, float y, float size, float activeAnim, float hoverAnim) { + syncTheme(); + float active = clamp01(activeAnim); + float hover = clamp01(hoverAnim); + float alpha = 0.12f + active * 0.88f; + int tint = mix(CHECK_COLOR, 0xFFFFFFFF, hover * 0.35f); + int argb = withAlpha(tint, alpha); + Renderer2D.COLOR.svg("check", x, y, size, size, SvgRenderOptions.overrideColor(argb)); + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/PickerDetailOwner.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/PickerDetailOwner.java new file mode 100644 index 0000000..64806ba --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/PickerDetailOwner.java @@ -0,0 +1,40 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +/** + * Optional extension for picker-backed settings that need an inline editor inside the picker overlay. + */ +public interface PickerDetailOwner { + float pickerDetailWidth(); + + void onPickerFocusChanged(String id); + + /** + * Regular picker clicks toggle selection by default. Detail-backed pickers can + * use grid clicks only as focus changes and expose add/remove actions inside + * their details panel. + */ + default boolean shouldToggleSelectionOnCardClick() { + return true; + } + + void renderPickerDetails(float x, float y, float w, float h, String focusedId, float mouseX, float mouseY); + + boolean mouseClickedPickerDetails(float mouseX, float mouseY, int button); + + default void mouseReleasedPickerDetails(float mouseX, float mouseY, int button) { + } + + /** + * Return true when the detail panel consumed the wheel event. + */ + default boolean mouseScrolledPickerDetails(float mouseX, float mouseY, double delta) { + return false; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ProtocolHeuristicsSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ProtocolHeuristicsSetting.java new file mode 100644 index 0000000..7bffac7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/ProtocolHeuristicsSetting.java @@ -0,0 +1,61 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.BooleanMapValue; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.config.values.SetValue; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; + +/** + * Compact Edit entry backed by per-module source toggles. The actual regex + * editor is a dedicated screen and stores its shared rules separately. + */ +public final class ProtocolHeuristicsSetting extends TextListSetting { + private final BooleanMapValue sources; + + public ProtocolHeuristicsSetting(String name, BooleanMapValue sources) { + super(name, new SetValue(name + "_editor_placeholder"), PickerMode.TEXT); + this.sources = sources; + } + + public BooleanMapValue sources() { + return sources; + } + + public void setSourceEnabled(String key, boolean enabled) { + if (sources == null || key == null) return; + sources.set(key, enabled); + if (getParent() != null) getParent().saveConfig(); + } + + @Override + public void mouseClicked(double mx, double my, int button) { + if (button != 0) return; + UiState ui = ui(); + if (!UnifiedSettingsSkin.inside(mx, my, ui.lastButtonX, ui.lastButtonY, ui.lastButtonW, ui.lastButtonH)) { + return; + } + ClickGuiRenderer.openProtocolHeuristicsEditor(this, getDisplayName()); + } + + @Override + public Object save() { + return sources == null ? null : sources.toJson(); + } + + @Override + public void load(Object value) { + if (sources != null) sources.fromJson(value); + } + + @Override + public ConfigValue getConfigValue() { + return sources; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/Setting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/Setting.java new file mode 100644 index 0000000..747cc0c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/Setting.java @@ -0,0 +1,482 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import net.minecraft.client.resources.language.I18n; +import dev.loki.lovisual.config.ConfigLang; +import dev.loki.lovisual.config.SettingOwner; +import dev.loki.lovisual.config.values.ConfigValue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +public abstract class Setting { + + protected static final float LABEL_PAD = 6f; + private static final ThreadLocal MISSING_REPORTER = new ThreadLocal<>(); + private static boolean LANG_READY = false; + protected final ConfigValue value; + private final String name; + private final Map cachedOptionDisplayNames = new HashMap<>(); + private SettingOwner parent; + private Supplier visibility = () -> true; + private Supplier unavailableReason = () -> null; + private boolean lastVisible = true; + private float visibilityAnim = 1f; + private long lastAnimTime = System.nanoTime(); + private String cachedDisplayName; + private String cachedTranslationKey; + private SettingOwner cachedTranslationOwner; + private final List commonI18nKeys = new ArrayList<>(); + private boolean i18nNameEnabled = true; + private boolean i18nOptionsEnabled = true; + + public Setting(String name, ConfigValue value) { + this.name = name; + this.value = value; + } + + public Setting(String name) { + this.name = name; + this.value = null; + } + + protected static float labelX(float x) { + return x + LABEL_PAD; + } + + private static String resolveFirstTranslation(java.util.List keys, boolean logIfMissing) { + if (keys == null || keys.isEmpty()) return null; + + if (!ensureLangReady()) { + return null; + } + + String firstLoggable = null; + + for (String key : keys) { + if (key == null || key.isBlank()) continue; + + boolean loggable = key.startsWith("setting."); + if (loggable && firstLoggable == null) { + firstLoggable = key; + } + + String translated = I18n.get(key); + if (!translated.equals(key)) { + return translated; + } + } + + if (logIfMissing && firstLoggable != null) { + MissingI18nReporter reporter = MISSING_REPORTER.get(); + if (reporter != null) { + reporter.missing(keys); + } + } + + return null; + } + + private static boolean ensureLangReady() { + if (LANG_READY) return true; + if (!I18n.get("setting.module.bind").equals("setting.module.bind")) { + LANG_READY = true; + } + return LANG_READY; + } + + public static boolean prepareI18nPreflightPass() { + LANG_READY = false; + return ensureLangReady(); + } + + public static AutoCloseable captureMissingI18n(MissingI18nReporter reporter) { + MissingI18nReporter previous = MISSING_REPORTER.get(); + if (reporter == null) { + MISSING_REPORTER.remove(); + } else { + MISSING_REPORTER.set(reporter); + } + return () -> { + if (previous == null) { + MISSING_REPORTER.remove(); + } else { + MISSING_REPORTER.set(previous); + } + }; + } + + // --- UI --- + public String getName() { + return name; + } + + public String getDisplayName() { + if (LANG_READY && cachedDisplayName != null) return cachedDisplayName; + + if (i18nNameEnabled) { + String translated = resolveFirstTranslation(getTranslationKeys(), true); + if (translated != null) { + if (LANG_READY) cachedDisplayName = translated; + return translated; + } + } + + if (value != null) { + String fallback = ConfigLang.getName(value); + if (fallback != null && !fallback.isBlank() && !fallback.equals(value.getName())) { + if (LANG_READY) cachedDisplayName = fallback; + return fallback; + } + } + + if (LANG_READY) cachedDisplayName = name; + return name; + } + + public String getOptionDisplayName(String optionId) { + if (optionId == null || optionId.isBlank()) return optionId; + + if (!i18nOptionsEnabled) { + return optionId; + } + + if (LANG_READY) { + String cached = cachedOptionDisplayNames.get(optionId); + if (cached != null) return cached; + } + + java.util.List optionKeys = optionTranslationKeys(optionId); + + String translated = resolveFirstTranslation(optionKeys, true); + String resolved = translated != null ? translated : optionId; + + if (LANG_READY) { + cachedOptionDisplayNames.put(optionId, resolved); + } + + return resolved; + } + + public SettingOwner getParent() { + return parent; + } + + public void setParent(SettingOwner owner) { + if (parent != owner) { + parent = owner; + invalidateNameCaches(); + return; + } + parent = owner; + } + + public String getTranslationKey() { + if (parent == null) return null; + if (cachedTranslationKey != null && cachedTranslationOwner == parent) { + return cachedTranslationKey; + } + String prefix = parent.getTranslationKeyPrefix(); + String id = getTranslationId(); + if (prefix == null || id == null || prefix.isBlank() || id.isBlank()) return null; + cachedTranslationOwner = parent; + cachedTranslationKey = prefix + "." + id; + return cachedTranslationKey; + } + + public java.util.List getTranslationKeys() { + java.util.List keys = new java.util.ArrayList<>(3); + + String local = getTranslationKey(); + if (local != null && !local.isBlank()) { + keys.add(local); + } + + for (String commonI18nKey : commonI18nKeys) { + if (commonI18nKey != null && !commonI18nKey.isBlank()) { + keys.add("setting.common." + commonI18nKey); + } + } + + String id = getTranslationId(); + if (id != null && !id.isBlank()) { + keys.add("setting.common." + id); + } + + return keys; + } + + public void setCommonI18nKey(String key) { + ArrayList keys = new ArrayList<>(); + addCommonI18nKey(keys, key); + setCommonI18nKeys(keys); + } + + public void setCommonI18nKeys(Iterable keys) { + ArrayList normalized = new ArrayList<>(); + if (keys != null) { + for (String key : keys) { + addCommonI18nKey(normalized, key); + } + } + if (!commonI18nKeys.equals(normalized)) { + commonI18nKeys.clear(); + commonI18nKeys.addAll(normalized); + invalidateNameCaches(); + } + } + + private static void addCommonI18nKey(ArrayList keys, String key) { + if (key == null) return; + String normalized = key.trim(); + if (!normalized.isBlank() && !keys.contains(normalized)) { + keys.add(normalized); + } + } + + public void setI18nEnabled(boolean name, boolean options) { + if (i18nNameEnabled != name || i18nOptionsEnabled != options) { + i18nNameEnabled = name; + i18nOptionsEnabled = options; + invalidateNameCaches(); + } + } + + /** + * Trigger i18n lookup so missing keys are logged early. + */ + public void preflightI18n() { + if (!i18nNameEnabled) return; + resolveFirstTranslation(getTranslationKeys(), true); + } + + protected void preflightOptionI18n(String optionId) { + if (!i18nOptionsEnabled) return; + if (optionId == null || optionId.isBlank()) return; + + resolveFirstTranslation(optionTranslationKeys(optionId), true); + } + + private java.util.List optionTranslationKeys(String optionId) { + ArrayList optionKeys = new ArrayList<>(); + if (optionId == null || optionId.isBlank()) return optionKeys; + ArrayList variants = optionIdVariants(optionId); + for (String base : getTranslationKeys()) { + if (base == null || base.isBlank()) continue; + for (String variant : variants) { + addOptionKey(optionKeys, base, variant); + } + } + return optionKeys; + } + + private static ArrayList optionIdVariants(String optionId) { + ArrayList variants = new ArrayList<>(); + addVariant(variants, optionId); + + String lower = optionId.toLowerCase(java.util.Locale.ROOT); + addVariant(variants, lower); + addNormalizedOptionVariants(variants, lower); + addVariant(variants, optionId.toUpperCase(java.util.Locale.ROOT)); + + if (!lower.isEmpty()) { + addVariant(variants, Character.toUpperCase(lower.charAt(0)) + lower.substring(1)); + } + + return variants; + } + + private static void addNormalizedOptionVariants(ArrayList variants, String lower) { + if (lower == null || lower.isBlank()) return; + String normalized = lower.trim() + .replace('-', '_') + .replace(' ', '_'); + while (normalized.contains("__")) { + normalized = normalized.replace("__", "_"); + } + normalized = stripEdgeUnderscores(normalized); + addVariant(variants, normalized); + + String compact = normalized.replace("_", ""); + addVariant(variants, compact); + } + + private static String stripEdgeUnderscores(String value) { + int start = 0; + int end = value.length(); + while (start < end && value.charAt(start) == '_') start++; + while (end > start && value.charAt(end - 1) == '_') end--; + return start == 0 && end == value.length() ? value : value.substring(start, end); + } + + private static void addVariant(ArrayList variants, String value) { + if (value != null && !value.isBlank() && !variants.contains(value)) { + variants.add(value); + } + } + + private static void addOptionKey(ArrayList keys, String base, String optionId) { + String key = base + ".option." + optionId; + if (!keys.contains(key)) { + keys.add(key); + } + } + + public Setting unavailableReason(Supplier supplier) { + this.unavailableReason = supplier == null ? () -> null : supplier; + return this; + } + + public boolean isAvailable() { + return getUnavailableReason().isEmpty(); + } + + public String getUnavailableReason() { + try { + String reason = unavailableReason.get(); + return reason == null ? "" : reason.trim(); + } catch (Exception ignored) { + return ""; + } + } + + public abstract void render(float x, float y, float width, float mouseX, float mouseY); + + public abstract void mouseClicked(double mx, double my, int button); + + public void mouseReleased(double mx, double my, int button) { + } + + /** + * Called by parent panels when a click happens outside this setting hitbox. + */ + public void mouseClickedOutside(double mx, double my, int button) { + } + + public boolean mouseScrolled(double mx, double my, double amount) { + return false; + } + + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return false; + } + + public boolean charTyped(char chr, int modifiers) { + return false; + } + + public abstract float getHeight(); + + /** + * Уникальный ID (для сериализации) + */ + public String getId() { + return name.toLowerCase().replace(" ", "_"); + } + + protected String getTranslationId() { + return getId(); + } + + // ====================================================== + // Сериализация (через ConfigValue) + // ====================================================== + + public Object save() { + if (value != null) + return value.toJson(); + return null; + } + + public void load(Object o) { + if (value != null) + value.fromJson(o); + } + + /** + * Underlying config value (used for persistence). + */ + public ConfigValue getConfigValue() { + return value; + } + + // ====================================================== + // Visibility + // ====================================================== + public Setting visibleWhen(Supplier supplier) { + this.visibility = supplier == null ? () -> true : supplier; + return this; + } + + public boolean isVisible() { + try { + return visibility.get(); + } catch (Exception e) { + return true; + } + } + + /** + * Smooth visibility progress 0..1 for animating show/hide. + */ + public float updateVisibilityAnim() { + boolean vis = isVisible(); + long now = System.nanoTime(); + float dt = (now - lastAnimTime) / 1_000_000_000f; + lastAnimTime = now; + if (dt <= 0f) dt = 0.0001f; + dt = Math.min(dt, 0.05f); // avoid huge jumps on long pauses + + if (vis && !lastVisible) { + visibilityAnim = 0f; // start animating from 0 when becoming visible + } + + float target = vis ? 1f : 0f; + float speed = 10f; // higher = faster + float step = Math.min(0.35f, dt * speed); // cap so it never jumps in a single frame + visibilityAnim += (target - visibilityAnim) * step; + + // clamp + if (visibilityAnim < 0.001f) visibilityAnim = 0f; + if (visibilityAnim > 0.999f) visibilityAnim = 1f; + + lastVisible = vis; + return visibilityAnim; + } + + public float getVisibilityAnim() { + return visibilityAnim; + } + + public boolean isVisibilityTargetVisible() { + return lastVisible; + } + + /** + * Height scaled by visibility animation, used for layout. + */ + public float getAnimatedHeight() { + return getHeight() * visibilityAnim; + } + + private void invalidateNameCaches() { + cachedDisplayName = null; + cachedTranslationKey = null; + cachedTranslationOwner = null; + cachedOptionDisplayNames.clear(); + } + + @FunctionalInterface + public interface MissingI18nReporter { + void missing(java.util.List keyChain); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingFactory.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingFactory.java new file mode 100644 index 0000000..7cd9e97 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingFactory.java @@ -0,0 +1,75 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.*; +import dev.loki.lovisual.config.values.*; + +/** + * Builds GUI settings from GUI-neutral SettingDef. + */ +public enum SettingFactory { + ; + + public static Setting fromDef(SettingDef def) { + if (def == null) return null; + Setting setting = switch (def.kind()) { + case BOOLEAN -> def.value() instanceof BooleanValue v + ? new BooleanSetting(def.getId(), v) : null; + case NUMBER -> def.value() instanceof NumberValue v + ? new SliderSetting<>(def.getId(), v) : null; + case MODE -> def.value() instanceof ModeValue v + ? new ModeSetting(def.getId(), v) + : def.value() instanceof EnumValue ev + ? new ModeSetting(def.getId(), ev) + : null; + case COLOR -> def.value() instanceof RGBAColorValue v + ? new ColorSetting(def.getId(), v) : null; + case COLOR_NO_ALPHA -> def.value() instanceof RGBColorValue v + ? new ColorSetting(def.getId(), v) : null; + case TEXT -> def.value() instanceof StringValue v + ? new TextSetting(def.getId(), v) : null; + case TEXT_LIST -> def.value() instanceof SetValue v + ? new TextListSetting(def.getId(), v, def.pickerMode()) + : def.value() instanceof ItemIdSetValue iv + ? new TextListSetting(def.getId(), iv, def.pickerMode()) + : null; + case COOLDOWN_RULES -> def.value() instanceof ItemCooldownRulesValue v + ? new CooldownRulesSetting(def.getId(), v) : null; + case PROTOCOL_HEURISTICS -> def.value() instanceof BooleanMapValue v + ? new ProtocolHeuristicsSetting(def.getId(), v) : null; + case GROUP -> def.value() instanceof BooleanMapValue v + ? new GroupSetting(def.getId(), v) : null; + case BIND -> def.value() instanceof KeyBindValue v + ? new FunctionBindSetting(def.getId(), v, def.bindMode() != null ? def.bindMode() : BindMode.PRESS) + : null; + }; + if (setting != null) { + setting.setCommonI18nKeys(def.commonI18nKeys()); + setting.setI18nEnabled(def.i18nNameEnabled(), def.i18nOptionsEnabled()); + setting.unavailableReason(def.unavailableReason()); + + if (def.visibility() != null) { + setting.visibleWhen(def.visibility()); + } + } + + return setting; + } + + public static java.util.List fromDefs(java.util.List defs) { + if (defs == null || defs.isEmpty()) return java.util.List.of(); + java.util.List out = new java.util.ArrayList<>(); + for (SettingDef def : defs) { + Setting setting = fromDef(def); + if (setting != null) out.add(setting); + } + return out; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderContext.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderContext.java new file mode 100644 index 0000000..3adda5d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderContext.java @@ -0,0 +1,57 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +public enum SettingRenderContext { + ; + private static final ThreadLocal CURRENT = + ThreadLocal.withInitial(() -> SettingRenderSurface.SETTINGS); + private static final ThreadLocal SCALE = + ThreadLocal.withInitial(() -> 1.0f); + + public static SettingRenderSurface current() { + return CURRENT.get(); + } + + public static float scale() { + Float value = SCALE.get(); + if (value == null || !Float.isFinite(value) || value <= 0.0f) return 1.0f; + return value; + } + + public static Scope push(SettingRenderSurface surface) { + return push(surface, 1.0f); + } + + public static Scope push(SettingRenderSurface surface, float scale) { + SettingRenderSurface previousSurface = CURRENT.get(); + float previousScale = scale(); + CURRENT.set(surface == null ? SettingRenderSurface.SETTINGS : surface); + SCALE.set(Float.isFinite(scale) && scale > 0.0f ? scale : 1.0f); + return new Scope(previousSurface, previousScale); + } + + public static final class Scope implements AutoCloseable { + private final SettingRenderSurface previousSurface; + private final float previousScale; + private boolean closed; + + private Scope(SettingRenderSurface previousSurface, float previousScale) { + this.previousSurface = previousSurface; + this.previousScale = previousScale; + } + + @Override + public void close() { + if (closed) return; + closed = true; + CURRENT.set(previousSurface); + SCALE.set(previousScale); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderSurface.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderSurface.java new file mode 100644 index 0000000..bf57f49 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRenderSurface.java @@ -0,0 +1,14 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +public enum SettingRenderSurface { + MODULES, + SETTINGS +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRendererBridge.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRendererBridge.java new file mode 100644 index 0000000..58bd64c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SettingRendererBridge.java @@ -0,0 +1,269 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.SystemCursor; + +enum SettingRendererBridge { + ; + private static final String ICON_WARN = "L"; + + static void render(BooleanSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(BooleanSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static float getHeight(BooleanSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(SliderSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(SliderSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static void mouseReleased(SliderSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseReleased(setting, mx, my, button); + } + + static void mouseClickedOutside(SliderSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClickedOutside(setting, mx, my, button); + } + + static boolean keyPressed(SliderSetting setting, int keyCode, int scanCode, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.keyPressed(setting, keyCode, scanCode, modifiers); + } + + static boolean charTyped(SliderSetting setting, char chr, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.charTyped(setting, chr, modifiers); + } + + static float getHeight(SliderSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(ColorSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(ColorSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static void mouseReleased(ColorSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseReleased(setting, mx, my, button); + } + + static void mouseClickedOutside(ColorSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClickedOutside(setting, mx, my, button); + } + + static boolean keyPressed(ColorSetting setting, int keyCode, int scanCode, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.keyPressed(setting, keyCode, scanCode, modifiers); + } + + static boolean charTyped(ColorSetting setting, char chr, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.charTyped(setting, chr, modifiers); + } + + static boolean mouseScrolled(ColorSetting setting, double mx, double my, double amount) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.mouseScrolled(setting, mx, my, amount); + } + + static float getHeight(ColorSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(TextSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(TextSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static void mouseClickedOutside(TextSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClickedOutside(setting, mx, my, button); + } + + static boolean keyPressed(TextSetting setting, int keyCode, int scanCode, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.keyPressed(setting, keyCode, scanCode, modifiers); + } + + static boolean charTyped(TextSetting setting, char chr, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.charTyped(setting, chr, modifiers); + } + + static float getHeight(TextSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(TextListSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(TextListSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static void mouseClickedOutside(TextListSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClickedOutside(setting, mx, my, button); + } + + static boolean keyPressed(TextListSetting setting, int keyCode, int scanCode, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.keyPressed(setting, keyCode, scanCode, modifiers); + } + + static boolean charTyped(TextListSetting setting, char chr, int modifiers) { + if (!setting.isAvailable()) return false; + return UnifiedSettingRenderer.charTyped(setting, chr, modifiers); + } + + static float getHeight(TextListSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(GroupSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(GroupSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static float getHeight(GroupSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(ModeSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(ModeSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static float getHeight(ModeSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(KeyBindSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(KeyBindSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static float getHeight(KeyBindSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + static void render(FunctionBindSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingRenderer.render(setting, x, y, w, mx, my); + renderUnavailable(setting, x, y, w, mx, my); + } + + static void mouseClicked(FunctionBindSetting setting, double mx, double my, int button) { + if (!setting.isAvailable()) return; + UnifiedSettingRenderer.mouseClicked(setting, mx, my, button); + } + + static float getHeight(FunctionBindSetting setting) { + return UnifiedSettingRenderer.getHeight(setting); + } + + private static void renderUnavailable(Setting setting, float x, float y, float w, float mx, float my) { + String reason = setting.getUnavailableReason(); + if (reason.isEmpty()) return; + + float scale = UnifiedSettingsSkin.scale(); + float h = Math.max(1.0f, setting.getHeight()); + boolean hovered = UnifiedSettingsSkin.inside(mx, my, x, y, w, h); + if (hovered) { + SystemCursor.set(SystemCursor.CursorType.NOT_ALLOWED); + } + + float inset = UnifiedSettingsSkin.metric(2.0f, 2.0f); + float radius = UnifiedSettingsSkin.metric(7.0f, 4.5f); + int veil = hovered ? 0x7A050607 : 0x63050607; + ClickGuiRenderer.drawRoundedRect(x + inset, y + inset, Math.max(1.0f, w - inset * 2.0f), Math.max(1.0f, h - inset * 2.0f), radius, veil); + ClickGuiRenderer.drawRoundedRectStroke( + x + inset, + y + inset, + Math.max(1.0f, w - inset * 2.0f), + Math.max(1.0f, h - inset * 2.0f), + radius, + UnifiedSettingsSkin.metric(0.7f, 0.45f), + UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.accentGradientStart(1.0f), hovered ? 145 : 98) + ); + + TextRenderer iconFont = Fonts.renderer("Icons", FontInfo.Type.Regular, UnifiedSettingsSkin.fontMedium()); + TextRenderer textFont = UnifiedSettingsSkin.fontMedium(); + float iconSize = UnifiedSettingsSkin.metric(16.0f, 7.4f); + float textSize = UnifiedSettingsSkin.metric(11.5f, 5.7f); + float pillH = Math.min(h - inset * 3.0f, UnifiedSettingsSkin.metric(20.0f, 11.5f)); + float pillY = y + h - pillH - UnifiedSettingsSkin.metric(4.0f, 2.8f); + float pillX = x + UnifiedSettingsSkin.metric(7.0f, 4.0f); + float pillW = Math.max(1.0f, w - UnifiedSettingsSkin.metric(14.0f, 8.0f)); + int pillA = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.SURFACE_HOVER, hovered ? 170 : 132); + int pillB = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT_SOFT, hovered ? 112 : 78); + ClickGuiRenderer.drawRoundedRectGradient(pillX, pillY, pillW, pillH, pillH * 0.5f, pillA, pillB, UnifiedSettingsSkin.ACCENT_GRADIENT_ANGLE); + + float iconY = pillY + (pillH - ClickGuiRenderer.textHeight(iconFont, iconSize)) * 0.5f - UnifiedSettingsSkin.metric(0.2f, 0.1f); + int iconColor = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.accentGradientStart(1.0f), hovered ? 255 : 220); + ClickGuiRenderer.drawText(iconFont, ICON_WARN, pillX + UnifiedSettingsSkin.metric(5.0f, 3.0f), iconY, iconSize, iconColor, false); + + float textX = pillX + UnifiedSettingsSkin.metric(22.0f, 11.0f); + float textMax = Math.max(1.0f, pillX + pillW - textX - UnifiedSettingsSkin.metric(5.0f, 3.0f)); + String fitted = UnifiedSettingsSkin.fit(textFont, reason, textSize, textMax); + float textY = pillY + (pillH - ClickGuiRenderer.textHeight(textFont, textSize)) * 0.5f - UnifiedSettingsSkin.metric(0.2f, 0.1f); + int textColor = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_PRIMARY, hovered ? 245 : 215); + ClickGuiRenderer.drawText(textFont, fitted, textX, textY, textSize, textColor, false); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SliderSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SliderSetting.java new file mode 100644 index 0000000..d909945 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/SliderSetting.java @@ -0,0 +1,99 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.util.text.TextSelection; + +public class SliderSetting extends Setting { + + private final NumberValue value; + private final UiState ui = new UiState(); + + public SliderSetting(String name, NumberValue value) { + super(name, value); + this.value = value; + } + + NumberValue value() { + return value; + } + + UiState ui() { + return ui; + } + + // ==================== CONFIG ==================== // + + @Override + public Object save() { + return value == null ? null : value.toJson(); + } + + @Override + public void load(Object json) { + if (value != null) value.fromJson(json); + } + + // ========================= UI ========================= // + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int button) { + SettingRendererBridge.mouseClicked(this, mx, my, button); + } + + @Override + public void mouseReleased(double mx, double my, int button) { + SettingRendererBridge.mouseReleased(this, mx, my, button); + } + + @Override + public void mouseClickedOutside(double mx, double my, int button) { + SettingRendererBridge.mouseClickedOutside(this, mx, my, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return SettingRendererBridge.keyPressed(this, keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return SettingRendererBridge.charTyped(this, chr, modifiers); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + static final class UiState { + final TextSelection editSelection = new TextSelection(); + boolean dragging; + float lastX, lastY, lastW; + float anim = 0f; + float hoverAnim = 0f; + float valueHoverAnim = 0f; + float editAnim = 0f; + float errorAnim = 0f; + float cursorBlink = 0f; + boolean editing = false; + String editBuffer = ""; + int editCursor = 0; + float inputHoverAnim = 0f; + float inputFocusGlowAnim = 0f; + float inputPressAnim = 0f; + float valueX, valueY, valueW, valueH; + float valueTextX, valueTextY, valueTextSize, valueTextPad; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextEditorOwner.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextEditorOwner.java new file mode 100644 index 0000000..f0f9451 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextEditorOwner.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +public interface TextEditorOwner { + void applyEditorText(String rawText); + + String getEditorText(); + + boolean isSingleLine(); +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextListSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextListSetting.java new file mode 100644 index 0000000..f89bf70 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextListSetting.java @@ -0,0 +1,153 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.config.values.ItemIdSetValue; +import dev.loki.lovisual.util.text.TextSelection; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Button + modal text editor or picker for editing Set-based values. + */ +public class TextListSetting extends Setting implements TextEditorOwner { + + private final ConfigValue> value; + private final boolean enforceMinecraftPrefix; + private final PickerMode pickerMode; + private final UiState ui = new UiState(); + public TextListSetting(String name, ConfigValue> value) { + this(name, value, PickerMode.TEXT); + } + + public TextListSetting(String name, ConfigValue> value, PickerMode pickerMode) { + super(name, value); + this.value = value; + this.enforceMinecraftPrefix = value instanceof ItemIdSetValue; + this.pickerMode = pickerMode == null ? PickerMode.TEXT : pickerMode; + } + + ConfigValue> value() { + return value; + } + + UiState ui() { + return ui; + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int button) { + SettingRendererBridge.mouseClicked(this, mx, my, button); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + @Override + public void mouseClickedOutside(double mx, double my, int button) { + SettingRendererBridge.mouseClickedOutside(this, mx, my, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return SettingRendererBridge.keyPressed(this, keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return SettingRendererBridge.charTyped(this, chr, modifiers); + } + + @Override + public void applyEditorText(String rawText) { + Set parsed = Arrays.stream(rawText.split("\\R")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(this::normalizeEntry) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + value.set(parsed); + if (getParent() != null) getParent().saveConfig(); + } + + public Set getValueSet() { + Set current = value.get(); + if (current == null) return new LinkedHashSet<>(); + return new LinkedHashSet<>(current); + } + + public void setValueSet(Set next) { + value.set(next == null ? new LinkedHashSet<>() : next); + if (getParent() != null) getParent().saveConfig(); + } + + public PickerMode getPickerMode() { + return pickerMode; + } + + public boolean isMinecraftPrefixEnforced() { + return enforceMinecraftPrefix; + } + + public String normalizeEntry(String line) { + if (!enforceMinecraftPrefix) return line; + if (line.contains(":")) return line; + return "minecraft:" + line; + } + + @Override + public String getEditorText() { + Set current = value.get(); + if (current == null || current.isEmpty()) return ""; + return String.join("\n", current); + } + + @Override + public boolean isSingleLine() { + return false; + } + + public enum PickerMode { + TEXT, + SCREENS, + BLOCKS, + ITEMS, + EQUIPPABLE_ARMOR, + ENCHANTMENTS, + ALL, + SOUNDS, + LIVING_ENTITIES, + ENTITIES + } + + static final class UiState { + final TextSelection editSelection = new TextSelection(); + float lastButtonX, lastButtonY, lastButtonW, lastButtonH; + float lastX, lastY, lastW; + float fieldX, fieldY, fieldW, fieldH; + float moduleHoverAnim; + float fieldHoverAnim; + float fieldFocusAnim; + float fieldErrorAnim; + float cursorBlink; + boolean editing; + String editBuffer = ""; + int editCursor; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextSetting.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextSetting.java new file mode 100644 index 0000000..e6f9d51 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/TextSetting.java @@ -0,0 +1,94 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.config.values.StringValue; +import dev.loki.lovisual.util.text.TextSelection; + +public class TextSetting extends Setting implements TextEditorOwner { + + private final StringValue value; + private final UiState ui = new UiState(); + + public TextSetting(String name, StringValue value) { + super(name, value); + this.value = value; + } + + private static String normalizeSingleLine(String text) { + if (text == null) return ""; + String normalized = text.replace("\r\n", "\n").replace('\r', '\n'); + return normalized.replace('\n', ' '); + } + + UiState ui() { + return ui; + } + + @Override + public void render(float x, float y, float w, float mx, float my) { + SettingRendererBridge.render(this, x, y, w, mx, my); + } + + @Override + public void mouseClicked(double mx, double my, int button) { + SettingRendererBridge.mouseClicked(this, mx, my, button); + } + + @Override + public void mouseClickedOutside(double mx, double my, int button) { + SettingRendererBridge.mouseClickedOutside(this, mx, my, button); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + return SettingRendererBridge.keyPressed(this, keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + return SettingRendererBridge.charTyped(this, chr, modifiers); + } + + @Override + public float getHeight() { + return SettingRendererBridge.getHeight(this); + } + + @Override + public void applyEditorText(String rawText) { + String next = normalizeSingleLine(rawText); + value.set(next); + if (getParent() != null) getParent().saveConfig(); + } + + @Override + public String getEditorText() { + String current = value.get(); + return current == null ? "" : current; + } + + @Override + public boolean isSingleLine() { + return true; + } + + static final class UiState { + final TextSelection editSelection = new TextSelection(); + float fieldX, fieldY, fieldW, fieldH; + float textX, textY, textSize; + float textScroll; + float hoverAnim; + float focusAnim; + float errorAnim; + float cursorBlink; + boolean editing; + String editBuffer = ""; + int editCursor; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingRenderer.java new file mode 100644 index 0000000..bed4d68 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingRenderer.java @@ -0,0 +1,2798 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.config.values.BindMode; +import dev.loki.lovisual.config.values.ColorValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.text.ClipboardUtil; +import dev.loki.lovisual.util.wav.ClickGuiSounds; + +import java.awt.*; +import java.util.HashSet; +import java.util.Set; + +enum UnifiedSettingRenderer { + ; + private static final float BIND_MARQUEE_SPEED = 22f; + private static final float BIND_MARQUEE_GAP = 14f; + private static final float BIND_MARQUEE_PAUSE_SEC = 0.8f; + + private static float m(float settings, float modules) { + return UnifiedSettingsSkin.metric(settings, modules); + } + + private static float s() { + return UnifiedSettingsSkin.scale(); + } + + private static boolean modules() { + return UnifiedSettingsSkin.modules(); + } + + // ------------------------------------------------------------ + // Boolean + // ------------------------------------------------------------ + + static void render(BooleanSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + BooleanSetting.UiState ui = setting.ui(); + LayoutBoolean layout = booleanLayout(setting, w); + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + ui.lastH = layout.rowH; + + boolean enabled = setting.get(); + float dt = AnimationUtility.deltaTime(); + ui.anim = AnimationUtility.approach(ui.anim, enabled ? 1f : 0f, dt, 13f); + boolean hover = UnifiedSettingsSkin.inside(mx, my, x, y, w, layout.rowH); + ui.hoverAnim = AnimationUtility.approach(ui.hoverAnim, hover ? 1f : 0f, dt, 12f); + + TextRenderer font = modules() ? UnifiedSettingsSkin.fontRegular() : ClickGuiRenderer.getIosevkaRegular(); + float labelSize = m(18f, 6.8f); + + float cardX = x + m(2f, 2f); + float cardY = y + m(3f, 2f); + float cardW = Math.max(1f, w - m(4f, 4f)); + float cardH = Math.max(m(28f, 17f), layout.rowH - m(6f, 4f)); + float radius = m(8f, 5f); + UnifiedSettingsSkin.drawCard(cardX, cardY, cardW, cardH, radius, 0.42f + ui.hoverAnim * 0.24f); + float cardStrokeAnim = Math.max(ui.hoverAnim, ui.anim); + if (cardStrokeAnim > 0.001f) { + int strokeA = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(ui.hoverAnim * 82f)); + int strokeB = UnifiedSettingsSkin.accentGradientStart(ui.anim * 0.50f); + ClickGuiRenderer.drawRoundedRectStroke(cardX, cardY, cardW, cardH, radius, m(0.65f, 0.42f), UnifiedSettingsSkin.mix(strokeA, strokeB, Math.max(ui.anim, ui.hoverAnim * 0.28f))); + } + + float sw = m(39f, 16f); + float sh = m(18f, 8f); + float sx = cardX + m(8f, 5f); + float sy = cardY + (cardH - sh) * 0.5f; + ui.lastControlX = sx; + ui.lastControlY = sy; + ui.lastControlW = sw; + ui.lastControlH = sh; + + int bgOffA = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE, UnifiedSettingsSkin.SURFACE_HOVER, 0.45f + ui.hoverAnim * 0.20f); + int bgOffB = UnifiedSettingsSkin.mix(bgOffA, 0xFF000000, 0.30f); + int bgOnA = UnifiedSettingsSkin.accentGradientStart(1f); + int bgOnB = UnifiedSettingsSkin.accentGradientEnd(1f); + int bgA = UnifiedSettingsSkin.mix(bgOffA, bgOnA, ui.anim); + int bgB = UnifiedSettingsSkin.mix(bgOffB, bgOnB, ui.anim); + ClickGuiRenderer.drawRoundedRectGradient(sx, sy, sw, sh, sh * 0.5f, bgA, bgB, UnifiedSettingsSkin.ACCENT_GRADIENT_ANGLE); + ClickGuiRenderer.drawRoundedRectStroke(sx, sy, sw, sh, sh * 0.5f, m(0.65f, 0.42f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 50 + Math.round(ui.hoverAnim * 44f))); + + float knob = sh - m(4f, 2.4f); + float kx = sx + m(2f, 1.2f) + (sw - knob - m(4f, 2.4f)) * AnimationUtility.easeInOutCubic(ui.anim); + float ky = sy + (sh - knob) * 0.5f; + int knobCol = UnifiedSettingsSkin.mix(0xFFECEEF2, 0xFFFFFFFF, ui.anim * 0.55f + ui.hoverAnim * 0.18f); + ClickGuiRenderer.drawRoundedRect(kx, ky, knob, knob, knob * 0.5f, knobCol); + + float labelX = sx + sw + m(9f, 6f); + int labelColor = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, 0.72f + ui.hoverAnim * 0.16f + ui.anim * 0.12f); + + ClickGuiRenderer.beginTextBatch(); + for (int i = 0; i < layout.lines.length; i++) { + ClickGuiRenderer.drawText(font, layout.lines[i], labelX, y + layout.labelY + i * (layout.lineH + m(2f, 0.7f)), labelSize, labelColor, false); + } + ClickGuiRenderer.endTextBatch(); + } + + static void mouseClicked(BooleanSetting setting, double mx, double my, int button) { + if (button != 0) return; + BooleanSetting.UiState ui = setting.ui(); + if (!UnifiedSettingsSkin.inside(mx, my, ui.lastX, ui.lastY, ui.lastW, ui.lastH)) return; + setting.set(!setting.get()); + ClickGuiSounds.changeMode(); + if (setting.getParent() != null) setting.getParent().saveConfig(); + } + + static float getHeight(BooleanSetting setting) { + return booleanLayout(setting, setting.ui().lastW > 0f ? setting.ui().lastW : m(170f, 115f)).rowH; + } + + private static LayoutBoolean booleanLayout(BooleanSetting setting, float width) { + BooleanSetting.UiState ui = setting.ui(); + TextRenderer font = modules() ? UnifiedSettingsSkin.fontRegular() : ClickGuiRenderer.getIosevkaRegular(); + float labelSize = m(18f, 6.8f); + float switchW = m(39f, 16f); + String labelText = setting.getDisplayName(); + boolean needsLayout = !Float.isFinite(ui.layoutW) + || Math.abs(ui.layoutW - width) > 0.5f + || !labelText.equals(ui.layoutLabel); + if (needsLayout) { + float lineH = UnifiedSettingsSkin.textHeight(font, labelSize); + float labelMaxW = Math.max(m(72f, 36f), width - switchW - m(31f, 18f)); + var linesList = ClickGuiRenderer.wrapText(font, labelText, labelSize, labelMaxW, 2); + String[] lines = linesList.toArray(new String[0]); + float gap = m(2f, 0.7f); + float textBlockH = lines.length * lineH + Math.max(0, lines.length - 1) * gap; + float contentH = Math.max(textBlockH, m(18f, 8f)); + float minRow = m(44f, 24f); + ui.layoutW = width; + ui.layoutLabel = labelText; + ui.layoutLines = lines; + ui.layoutLineH = lineH; + ui.layoutRowH = Math.max(minRow, contentH + m(14f, 8f)); + ui.layoutLabelY = (ui.layoutRowH - textBlockH) * 0.5f; + ui.layoutControlY = (ui.layoutRowH - m(18f, 8f)) * 0.5f; + } + return new LayoutBoolean(ui.layoutLines, ui.layoutLineH, ui.layoutLabelY, ui.layoutControlY, ui.layoutRowH); + } + + static void render(SliderSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + SliderSetting.UiState ui = setting.ui(); + NumberValue value = setting.value(); + double min = value.getMin().doubleValue(); + double max = value.getMax().doubleValue(); + double val = value.get().doubleValue(); + + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + + float dt = AnimationUtility.deltaTime(); + if (ui.dragging && !ui.editing) { + setSliderValueFromMouse(setting, mx, x + m(6f, 5f), Math.max(1f, w - m(6f, 5f) * 2f), min, max); + val = value.get().doubleValue(); + } + + float trackPad = m(6f, 5f); + float trackX = x + trackPad; + float trackW = Math.max(1f, w - trackPad * 2f); + float trackY = y + m(38f, 15f); + float trackH = m(2.4f, 1.2f); + + float target = max == min ? 0f : (float) ((val - min) / (max - min)); + target = UnifiedSettingsSkin.clamp01(target); + ui.anim = AnimationUtility.approach(ui.anim, target, 0.40f); + boolean trackHover = UnifiedSettingsSkin.inside(mx, my, trackX, trackY - m(6f, 2f), trackW, trackH + m(12f, 4f)); + if ((trackHover && !ui.editing) || ui.dragging) { + SystemCursor.set(SystemCursor.CursorType.RESIZE_HORIZONTAL); + } + ui.hoverAnim = AnimationUtility.approach(ui.hoverAnim, trackHover ? 1f : 0f, dt, 12f); + ui.editAnim = AnimationUtility.approach(ui.editAnim, ui.editing ? 1f : 0f, dt, 14f); + ui.errorAnim = AnimationUtility.approach(ui.errorAnim, 0f, dt, 7.5f); + if (ui.editing) ui.cursorBlink += dt * 2.3f; + else ui.cursorBlink = 0f; + + TextRenderer font = modules() ? UnifiedSettingsSkin.fontRegular() : ClickGuiRenderer.getIosevkaRegular(); + float labelSize = m(17f, 6.8f); + float valueSize = m(17f, 6.8f); + float textY = y + m(6f, 1.9f); + String display = ui.editing ? ui.editBuffer : value.toDisplay(); + if (display == null) display = ""; + float valuePad = m(6f, 4f); + float rawValueW = UnifiedSettingsSkin.textWidth(font, display.isEmpty() ? "0" : display, valueSize) + valuePad * 2f; + float valueW = Math.max(m(34f, 20f), rawValueW); + valueW = Math.min(valueW, Math.max(m(46f, 28f), w * 0.48f)); + ui.valueX = x + w - valueW - trackPad; + ui.valueY = textY - m(4f, 1.5f); + ui.valueW = valueW; + ui.valueH = UnifiedSettingsSkin.textHeight(font, valueSize) + m(8f, 4f); + boolean valueHover = UnifiedSettingsSkin.inside(mx, my, ui.valueX, ui.valueY, ui.valueW, ui.valueH); + ui.valueHoverAnim = AnimationUtility.approach(ui.valueHoverAnim, valueHover ? 1f : 0f, dt, 12f); + ui.inputHoverAnim = AnimationUtility.approach(ui.inputHoverAnim, valueHover ? 1f : 0f, dt, 15f); + ui.inputFocusGlowAnim = AnimationUtility.approach(ui.inputFocusGlowAnim, ui.editing ? 1f : 0f, dt, 12f); + ui.inputPressAnim = AnimationUtility.approach(ui.inputPressAnim, ui.editing ? 1f : 0f, dt, 10f); + + int textCol = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_PRIMARY, 0xFFFF7777, ui.errorAnim * 0.85f); + float fieldAnim = Math.max(ui.inputHoverAnim * 0.62f, ui.inputFocusGlowAnim); + int fieldBg = UnifiedSettingsSkin.withAlpha( + UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, fieldAnim * 0.72f), + Math.round(fieldAnim * 104f) + ); + int fieldStrokeBase = UnifiedSettingsSkin.mix( + UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(fieldAnim * 70f)), + UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, Math.round(ui.inputFocusGlowAnim * 160f)), + ui.inputFocusGlowAnim + ); + int fieldStroke = UnifiedSettingsSkin.mix(fieldStrokeBase, 0xFFFF5F66, ui.errorAnim); + + ClickGuiRenderer.beginTextBatch(); + String label = setting.getDisplayName(); + float labelMax = Math.max(1f, ui.valueX - trackX - m(7f, 5f)); + ClickGuiRenderer.drawText(font, UnifiedSettingsSkin.fit(font, label, labelSize, labelMax), trackX, textY, labelSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + ClickGuiRenderer.endTextBatch(); + + if (fieldAnim > 0.001f || ui.errorAnim > 0.001f) { + float lift = ui.inputFocusGlowAnim * m(1.0f, 0.45f); + if (UnifiedSettingsSkin.SURFACE_GRADIENT_ENABLED) { + UnifiedSettingsSkin.drawSurface(ui.valueX, ui.valueY - lift, ui.valueW, ui.valueH + lift * 2f, m(6f, 3.5f), fieldAnim); + } else { + ClickGuiRenderer.drawRoundedRect(ui.valueX, ui.valueY - lift, ui.valueW, ui.valueH + lift * 2f, m(6f, 3.5f), fieldBg); + } + if (ui.inputFocusGlowAnim > 0.02f) { + ClickGuiRenderer.drawRoundedRectStrokeGradient(ui.valueX, ui.valueY - lift, ui.valueW, ui.valueH + lift * 2f, m(6f, 3.5f), m(0.8f, 0.45f), UnifiedSettingsSkin.accentGradientStart(0.72f), UnifiedSettingsSkin.accentGradientEnd(0.72f), UnifiedSettingsSkin.ACCENT_GRADIENT_ANGLE); + } else { + ClickGuiRenderer.drawRoundedRectStroke(ui.valueX, ui.valueY - lift, ui.valueW, ui.valueH + lift * 2f, m(6f, 3.5f), m(0.7f, 0.45f), fieldStroke); + } + if (ui.errorAnim > 0.01f) { + ClickGuiRenderer.drawRoundedRectStroke(ui.valueX, ui.valueY - lift, ui.valueW, ui.valueH + lift * 2f, m(6f, 3.5f), m(1.0f, 0.55f), UnifiedSettingsSkin.withAlpha(0xFFFF5F66, Math.round(170f * ui.errorAnim))); + } + } + + float textBaseY = y + m(6f, 1.9f); + ui.valueTextSize = valueSize; + ui.valueTextPad = valuePad; + ui.valueTextY = textBaseY; + String drawValue = ui.editing ? display : UnifiedSettingsSkin.fit(font, display, valueSize, ui.valueW - valuePad * 2f); + float drawW = UnifiedSettingsSkin.textWidth(font, drawValue, valueSize); + float drawX = ui.editing ? ui.valueX + valuePad : ui.valueX + ui.valueW - drawW - valuePad; + ui.valueTextX = drawX; + boolean textClip = ScissorFunction.pushRaw(ui.valueX + valuePad * 0.65f, ui.valueY, Math.max(1f, ui.valueW - valuePad * 1.3f), ui.valueH); + if (ui.editing) { + renderInlineSelection(font, ui.editBuffer, sliderSelStart(ui), sliderSelEnd(ui), drawX, textBaseY, valueSize, ui.valueH, 0f); + } + ClickGuiRenderer.drawText(font, drawValue, drawX, textBaseY, valueSize, textCol, false); + if (ui.editing && ((int) (ui.cursorBlink * 2f) & 1) == 0) { + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + float cx = drawX + UnifiedSettingsSkin.textWidth(font, ui.editBuffer.substring(0, cursor), valueSize) + m(0.5f, 0.3f); + ClickGuiRenderer.drawRect(cx, ui.valueY + m(3f, 1.5f), m(0.75f, 0.45f), ui.valueH - m(6f, 3f), UnifiedSettingsSkin.withAlpha(textCol, 205)); + } + if (textClip) ScissorFunction.pop(); + + int baseTrack = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE, UnifiedSettingsSkin.SURFACE_HOVER, 0.55f + ui.hoverAnim * 0.15f); + ClickGuiRenderer.drawRoundedRect(trackX, trackY, trackW, trackH, 0.6f * s(), baseTrack); + float fillW = trackW * ui.anim; + if (fillW > 0.5f) { + ClickGuiRenderer.drawGradientRect(trackX, trackY, fillW, trackH, UnifiedSettingsSkin.accentGradientStart(1f), UnifiedSettingsSkin.accentGradientEnd(1f), UnifiedSettingsSkin.accentGradientEnd(1f), UnifiedSettingsSkin.accentGradientStart(1f)); + } + + float knobW = m(2.4f, 1.2f); + float knobH = m(7.2f, 4.2f); + float knobX = trackX + fillW - knobW * 0.5f; + float knobY = trackY - (knobH - trackH) * 0.5f; + int knobCol = UnifiedSettingsSkin.mix(0xFFE1E1E1, UnifiedSettingsSkin.accentGradientStart(1f), 0.25f + ui.hoverAnim * 0.20f); + ClickGuiRenderer.drawRoundedRect(knobX, knobY, knobW, knobH, knobW * 0.5f, knobCol); + } + + // ------------------------------------------------------------ + // Slider + // ------------------------------------------------------------ + + static void mouseClicked(SliderSetting setting, double mx, double my, int button) { + if (button != 0) return; + float trackPad = m(6f, 5f); + SliderSetting.UiState ui = setting.ui(); + if (ui.editing) { + if (UnifiedSettingsSkin.inside(mx, my, ui.valueX, ui.valueY, ui.valueW, ui.valueH)) { + setSliderEditCursorFromMouse(setting, (float) mx, false); + } else { + commitSliderEdit(setting, true); + } + return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.valueX, ui.valueY, ui.valueW, ui.valueH)) { + beginSliderEdit(setting); + setSliderEditCursorFromMouse(setting, (float) mx, false); + return; + } + float trackX = ui.lastX + trackPad; + float trackW = Math.max(1f, ui.lastW - trackPad * 2f); + float trackY = ui.lastY + m(38f, 15f); + float trackH = m(2.4f, 1.2f); + if (!UnifiedSettingsSkin.inside(mx, my, trackX, trackY - m(6f, 2f), trackW, trackH + m(12f, 4f))) return; + ui.dragging = true; + setSliderValueFromMouse(setting, (float) mx, trackX, trackW, setting.value().getMin().doubleValue(), setting.value().getMax().doubleValue()); + } + + static void mouseReleased(SliderSetting setting, double mx, double my, int button) { + if (button != 0 || !setting.ui().dragging) return; + setting.ui().dragging = false; + if (setting.getParent() != null) setting.getParent().saveConfig(); + } + + static void mouseClickedOutside(SliderSetting setting, double mx, double my, int button) { + if (button != 0) return; + SliderSetting.UiState ui = setting.ui(); + if (!ui.editing) return; + commitSliderEdit(setting, true); + } + + static boolean keyPressed(SliderSetting setting, int keyCode, int scanCode, int modifiers) { + SliderSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (ctrl && keyCode == GLFW.GLFW_KEY_A) { + selectAllSliderText(ui); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_C) { + ClipboardUtil.copy(selectedOrAllSliderText(ui)); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_V) { + insertSliderText(setting, ClipboardUtil.get()); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_X) { + ClipboardUtil.copy(selectedOrAllSliderText(ui)); + deleteSliderSelection(ui); + return true; + } + switch (keyCode) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> { + commitSliderEdit(setting, true); + return true; + } + case GLFW.GLFW_KEY_ESCAPE -> { + cancelSliderEdit(setting); + return true; + } + case GLFW.GLFW_KEY_BACKSPACE -> { + if (!deleteSliderSelection(ui) && ui.editCursor > 0) { + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor - 1) + ui.editBuffer.substring(ui.editCursor); + ui.editCursor--; + } + validateSliderLive(setting, ui); + return true; + } + case GLFW.GLFW_KEY_DELETE -> { + if (!deleteSliderSelection(ui) && ui.editCursor < ui.editBuffer.length()) { + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor) + ui.editBuffer.substring(ui.editCursor + 1); + } + validateSliderLive(setting, ui); + return true; + } + case GLFW.GLFW_KEY_LEFT -> { + moveSliderCursor(ui, -1, shift); + return true; + } + case GLFW.GLFW_KEY_RIGHT -> { + moveSliderCursor(ui, 1, shift); + return true; + } + case GLFW.GLFW_KEY_HOME -> { + setSliderCursor(ui, 0, shift); + return true; + } + case GLFW.GLFW_KEY_END -> { + setSliderCursor(ui, ui.editBuffer.length(), shift); + return true; + } + } + return true; + } + + static boolean charTyped(SliderSetting setting, char chr, int modifiers) { + SliderSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + if (chr == ',' || chr == '.') { + if (sliderInteger(setting) || sliderTextHasDecimal(ui.editBuffer)) { + pulseSliderError(ui); + return true; + } + insertSliderTextRaw(ui, "."); + validateSliderLive(setting, ui); + return true; + } + if (chr == '-') { + if (setting.value().getMin().doubleValue() >= 0.0) { + pulseSliderError(ui); + return true; + } + if (hasSliderSelection(ui)) deleteSliderSelection(ui); + if (ui.editCursor != 0 || ui.editBuffer.indexOf('-') >= 0) { + pulseSliderError(ui); + return true; + } + insertSliderTextRaw(ui, "-"); + validateSliderLive(setting, ui); + return true; + } + if (chr >= '0' && chr <= '9') { + insertSliderTextRaw(ui, String.valueOf(chr)); + validateSliderLive(setting, ui); + return true; + } + pulseSliderError(ui); + return true; + } + + static float getHeight(SliderSetting setting) { + return m(58f, 24f); + } + + private static boolean sliderInteger(SliderSetting setting) { + Object current = setting.value().get(); + return current instanceof Integer || current instanceof Long; + } + + private static void beginSliderEdit(SliderSetting setting) { + SliderSetting.UiState ui = setting.ui(); + ui.editing = true; + ui.dragging = false; + ui.editBuffer = String.valueOf(setting.value().get()).replace(',', '.'); + ui.editCursor = ui.editBuffer.length(); + ui.editSelection.clear(); + ui.errorAnim = 0f; + ui.cursorBlink = 0f; + } + + private static void cancelSliderEdit(SliderSetting setting) { + SliderSetting.UiState ui = setting.ui(); + ui.editing = false; + ui.editBuffer = ""; + ui.editCursor = 0; + ui.editSelection.clear(); + ui.errorAnim = 0f; + } + + private static void commitSliderEdit(SliderSetting setting, boolean save) { + SliderSetting.UiState ui = setting.ui(); + String raw = ui.editBuffer == null ? "" : ui.editBuffer.trim().replace(',', '.'); + if (!sliderTextCanCommit(setting, raw)) { + pulseSliderError(ui); + return; + } + try { + double parsed = Double.parseDouble(raw); + double min = setting.value().getMin().doubleValue(); + double max = setting.value().getMax().doubleValue(); + parsed = Math.max(min, Math.min(max, parsed)); + if (sliderInteger(setting)) parsed = Math.round(parsed); + else parsed = Math.round(parsed * 1000d) / 1000d; + N next = setting.value().castToType(parsed); + setting.value().set(next); + ui.editing = false; + ui.editBuffer = ""; + ui.editCursor = 0; + ui.editSelection.clear(); + if (save && setting.getParent() != null) setting.getParent().saveConfig(); + } catch (Throwable ignored) { + pulseSliderError(ui); + } + } + + private static boolean sliderTextCanCommit(SliderSetting setting, String raw) { + if (raw == null) return false; + String text = raw.trim().replace(',', '.'); + if (text.isEmpty() || text.equals("-") || text.equals(".") || text.equals("-.")) return false; + if (sliderInteger(setting) && text.indexOf('.') >= 0) return false; + try { + double parsed = Double.parseDouble(text); + return Double.isFinite(parsed); + } catch (Throwable ignored) { + return false; + } + } + + private static String sanitizeSliderText(String text, boolean integer) { + if (text == null) return ""; + StringBuilder out = new StringBuilder(); + boolean dot = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c >= '0' && c <= '9') { + out.append(c); + } else if (c == '-' && out.length() == 0) { + out.append('-'); + } else if (!integer && (c == '.' || c == ',') && !dot) { + out.append('.'); + dot = true; + } + } + return out.toString(); + } + + private static boolean sliderTextHasDecimal(String text) { + return text != null && (text.indexOf('.') >= 0 || text.indexOf(',') >= 0); + } + + private static void validateSliderLive(SliderSetting setting, SliderSetting.UiState ui) { + if (ui.editBuffer == null || ui.editBuffer.isEmpty() || ui.editBuffer.equals("-") || ui.editBuffer.equals(".")) + return; + if (!sliderTextCanCommit(setting, ui.editBuffer)) pulseSliderError(ui); + } + + private static void setSliderEditCursorFromMouse(SliderSetting setting, float mx, boolean shift) { + SliderSetting.UiState ui = setting.ui(); + TextRenderer font = modules() ? UnifiedSettingsSkin.fontRegular() : ClickGuiRenderer.getIosevkaRegular(); + int cursor = caretFromText(font, ui.editBuffer == null ? "" : ui.editBuffer, ui.valueTextSize <= 0f ? m(17f, 6.8f) : ui.valueTextSize, ui.valueTextX, mx); + setSliderCursor(ui, cursor, shift); + } + + private static void selectAllSliderText(SliderSetting.UiState ui) { + ui.editSelection.begin(0, 0); + ui.editSelection.updateCaret(0, ui.editBuffer == null ? 0 : ui.editBuffer.length()); + ui.editCursor = ui.editBuffer == null ? 0 : ui.editBuffer.length(); + } + + private static boolean hasSliderSelection(SliderSetting.UiState ui) { + return ui.editSelection.hasRange(); + } + + private static int sliderSelStart(SliderSetting.UiState ui) { + return hasSliderSelection(ui) ? Math.max(0, Math.min(ui.editSelection.start(), ui.editBuffer.length())) : -1; + } + + private static int sliderSelEnd(SliderSetting.UiState ui) { + return hasSliderSelection(ui) ? Math.max(0, Math.min(ui.editSelection.end(), ui.editBuffer.length())) : -1; + } + + private static String selectedOrAllSliderText(SliderSetting.UiState ui) { + if (hasSliderSelection(ui)) return ui.editBuffer.substring(sliderSelStart(ui), sliderSelEnd(ui)); + return ui.editBuffer == null ? "" : ui.editBuffer; + } + + private static boolean deleteSliderSelection(SliderSetting.UiState ui) { + if (!hasSliderSelection(ui)) return false; + int start = sliderSelStart(ui); + int end = sliderSelEnd(ui); + ui.editBuffer = ui.editBuffer.substring(0, start) + ui.editBuffer.substring(end); + ui.editCursor = start; + ui.editSelection.clear(); + return true; + } + + private static void insertSliderText(SliderSetting setting, String text) { + String sanitized = sanitizeSliderText(text, sliderInteger(setting)); + if (sanitized.isEmpty()) { + pulseSliderError(setting.ui()); + return; + } + SliderSetting.UiState ui = setting.ui(); + if (hasSliderSelection(ui)) deleteSliderSelection(ui); + for (int i = 0; i < sanitized.length(); i++) { + char c = sanitized.charAt(i); + if (c == '.' && (sliderInteger(setting) || sliderTextHasDecimal(ui.editBuffer))) { + pulseSliderError(ui); + continue; + } + if (c == '-' && (setting.value().getMin().doubleValue() >= 0.0 || ui.editCursor != 0 || ui.editBuffer.indexOf('-') >= 0)) { + pulseSliderError(ui); + continue; + } + insertSliderTextRaw(ui, String.valueOf(c)); + } + validateSliderLive(setting, ui); + } + + private static void insertSliderTextRaw(SliderSetting.UiState ui, String text) { + if (text == null || text.isEmpty()) return; + deleteSliderSelection(ui); + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + ui.editBuffer = ui.editBuffer.substring(0, cursor) + text + ui.editBuffer.substring(cursor); + ui.editCursor = cursor + text.length(); + ui.editSelection.clear(); + } + + private static void moveSliderCursor(SliderSetting.UiState ui, int dir, boolean shift) { + setSliderCursor(ui, Math.max(0, Math.min(ui.editBuffer.length(), ui.editCursor + dir)), shift); + } + + private static void setSliderCursor(SliderSetting.UiState ui, int pos, boolean shift) { + int next = Math.max(0, Math.min(ui.editBuffer == null ? 0 : ui.editBuffer.length(), pos)); + if (shift) { + if (!ui.editSelection.hasRange()) ui.editSelection.begin(0, ui.editCursor); + ui.editSelection.updateCaret(0, next); + } else { + ui.editSelection.clear(); + } + ui.editCursor = next; + ui.cursorBlink = 0f; + } + + private static void pulseSliderError(SliderSetting.UiState ui) { + ui.errorAnim = 1f; + } + + private static void setSliderValueFromMouse(SliderSetting setting, float mx, float trackX, float trackW, double min, double max) { + float percent = UnifiedSettingsSkin.clamp01((mx - trackX) / Math.max(1f, trackW)); + double nextRaw = min + (max - min) * percent; + if (setting.value().get() instanceof Integer || setting.value().get() instanceof Long) + nextRaw = Math.round(nextRaw); + else nextRaw = Math.round(nextRaw * 100d) / 100d; + N current = setting.value().get(); + N next = setting.value().castToType(nextRaw); + if (Double.compare(current.doubleValue(), next.doubleValue()) != 0) { + setting.value().set(next); + ClickGuiSounds.sliderMove(); + } + } + + static void render(ModeSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + ModeSetting.UiState ui = setting.ui(); + ui.baseX = x; + ui.baseY = y; + ui.baseW = w; + layoutMode(setting); + + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 6.5f); + ClickGuiRenderer.drawText(font, setting.getDisplayName(), x + m(5f, 5f), y + m(4f, 1.3f), size, UnifiedSettingsSkin.TEXT_PRIMARY, false); + renderOptionContainer(x + m(5f, 5f), y + m(24f, 9.7f), ui.selectW, optionContainerHeightMode(ui), false); + + for (ModeSetting.OptionLayout opt : ui.optionLayouts) { + float ox = x + opt.x(); + float oy = y + opt.y(); + boolean active = opt.getId().equals(setting.selectedId()); + boolean hover = UnifiedSettingsSkin.inside(mx, my, ox - m(2f, 2f), oy - m(1f, 1f), opt.w() + m(4f, 4f), m(16f, 8f)); + float hoverAnim = AnimationUtility.approach(ui.hoverAnims.getOrDefault(opt.getId(), 0f), hover ? 1f : 0f, 0.20f); + ui.hoverAnims.put(opt.getId(), hoverAnim); + int color = active ? UnifiedSettingsSkin.ACCENT : UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, hoverAnim * 0.50f); + ClickGuiRenderer.drawText(font, opt.label(), ox, oy, size, color, false); + } + } + + // ------------------------------------------------------------ + // Mode / Group chips + // ------------------------------------------------------------ + + static void mouseClicked(ModeSetting setting, double mx, double my, int button) { + if (button != 0) return; + ModeSetting.UiState ui = setting.ui(); + for (ModeSetting.OptionLayout opt : ui.optionLayouts) { + float ox = ui.baseX + opt.x(); + float oy = ui.baseY + opt.y(); + if (!UnifiedSettingsSkin.inside(mx, my, ox - m(2f, 2f), oy - m(1f, 1f), opt.w() + m(4f, 4f), m(16f, 8f))) + continue; + String before = setting.selectedId(); + setting.selectId(opt.getId()); + if (!opt.getId().equals(before)) ClickGuiSounds.changeMode(); + if (setting.onChange() != null) setting.onChange().run(); + if (setting.getParent() != null) setting.getParent().saveConfig(); + return; + } + } + + static float getHeight(ModeSetting setting) { + layoutMode(setting); + return setting.ui().cachedHeight; + } + + private static void layoutMode(ModeSetting setting) { + ModeSetting.UiState ui = setting.ui(); + float width = ui.baseW > 0f ? ui.baseW : m(160f, 115f); + int count = setting.optionIds().size(); + if (Float.isFinite(ui.lastLayoutW) && Math.abs(ui.lastLayoutW - width) <= 0.5f && ui.layoutOptionCount == count) + return; + ui.optionLayouts.clear(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 6.5f); + float maxW = Math.max(1f, width - m(10f, 10f)); + float offsetX = 0f, offsetY = 0f, maxRowW = 0f; + for (String id : setting.optionIds()) { + String label = setting.getOptionDisplayName(id); + float tw = UnifiedSettingsSkin.textWidth(font, label, size) + m(2f, 2f); + if (offsetX + tw >= maxW && offsetX > 0f) { + maxRowW = Math.max(maxRowW, offsetX); + offsetX = 0f; + offsetY += m(16f, 8f); + } + ui.optionLayouts.add(new ModeSetting.OptionLayout(id, label, m(7f, 6f) + offsetX, m(27f, 10.7f) + offsetY, tw)); + offsetX += tw; + } + maxRowW = Math.max(maxRowW, offsetX); + ui.selectW = Math.max(0f, maxRowW + m(2f, 2f)); + ui.cachedHeight = m(45f, 22f) + offsetY; + ui.lastLayoutW = width; + ui.layoutOptionCount = count; + } + + static void render(GroupSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + GroupSetting.UiState ui = setting.ui(); + ui.baseX = x; + ui.baseY = y; + ui.baseW = w; + layoutGroup(setting); + + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 6.5f); + ClickGuiRenderer.drawText(font, setting.getDisplayName(), x + m(5f, 5f), y + m(4f, 1.7f), size, UnifiedSettingsSkin.TEXT_PRIMARY, false); + renderOptionContainer(x + m(6f, 6f), y + m(24f, 9.7f), Math.max(1f, containerWidth(ui.optionLayouts)), optionContainerHeightGroup(ui), false); + + for (GroupSetting.OptionLayout opt : ui.optionLayouts) { + float ox = x + opt.x(); + float oy = y + opt.y(); + boolean active = setting.value().get(opt.getId()); + boolean hover = UnifiedSettingsSkin.inside(mx, my, ox - m(2f, 2f), oy - m(1f, 1f), opt.w() + m(4f, 4f), m(16f, 8f)); + float hoverAnim = AnimationUtility.approach(ui.hoverAnims.getOrDefault(opt.getId(), 0f), hover ? 1f : 0f, 0.20f); + float activeAnim = AnimationUtility.approach(ui.selectAnims.getOrDefault(opt.getId(), active ? 1f : 0f), active ? 1f : 0f, 0.22f); + ui.hoverAnims.put(opt.getId(), hoverAnim); + ui.selectAnims.put(opt.getId(), activeAnim); + int color = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, 0.72f + hoverAnim * 0.28f); + color = UnifiedSettingsSkin.mix(color, UnifiedSettingsSkin.ACCENT, activeAnim); + ClickGuiRenderer.drawText(font, opt.label(), ox, oy, size, color, false); + } + } + + static void mouseClicked(GroupSetting setting, double mx, double my, int button) { + if (button != 0) return; + GroupSetting.UiState ui = setting.ui(); + for (GroupSetting.OptionLayout opt : ui.optionLayouts) { + float ox = ui.baseX + opt.x(); + float oy = ui.baseY + opt.y(); + if (!UnifiedSettingsSkin.inside(mx, my, ox - m(2f, 2f), oy - m(1f, 1f), opt.w() + m(4f, 4f), m(16f, 8f))) + continue; + setting.value().set(opt.getId(), !setting.value().get(opt.getId())); + ClickGuiSounds.changeMode(); + if (setting.getParent() != null) setting.getParent().saveConfig(); + return; + } + } + + static float getHeight(GroupSetting setting) { + layoutGroup(setting); + return setting.ui().cachedHeight; + } + + private static void layoutGroup(GroupSetting setting) { + GroupSetting.UiState ui = setting.ui(); + float width = ui.baseW > 0f ? ui.baseW : m(160f, 115f); + int count = visibleGroupCount(setting); + if (Float.isFinite(ui.lastLayoutW) && Math.abs(ui.lastLayoutW - width) <= 0.5f && ui.layoutOptionCount == count) + return; + ui.optionLayouts.clear(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 6.5f); + float maxW = Math.max(1f, width - m(10f, 10f)); + float offsetX = 0f, offsetY = 0f, maxRowW = 0f; + Set alive = new HashSet<>(); + for (String id : setting.value().getAll().keySet()) { + if (!setting.isOptionVisible(id)) continue; + String label = setting.getOptionDisplayName(id); + float tw = UnifiedSettingsSkin.textWidth(font, label, size) + m(4f, 4f); + if (offsetX + tw >= maxW && offsetX > 0f) { + maxRowW = Math.max(maxRowW, offsetX); + offsetX = 0f; + offsetY += m(16f, 8f); + } + ui.optionLayouts.add(new GroupSetting.OptionLayout(id, label, m(8f, 8f) + offsetX, m(27f, 10.7f) + offsetY, tw)); + offsetX += tw; + alive.add(id); + } + maxRowW = Math.max(maxRowW, offsetX); + ui.cachedHeight = m(45f, 22f) + offsetY; + ui.lastLayoutW = width; + ui.layoutOptionCount = count; + ui.hoverAnims.keySet().removeIf(key -> !alive.contains(key)); + ui.selectAnims.keySet().removeIf(key -> !alive.contains(key)); + } + + private static int visibleGroupCount(GroupSetting setting) { + int count = 0; + for (String id : setting.value().getAll().keySet()) if (setting.isOptionVisible(id)) count++; + return count; + } + + private static float containerWidth(java.util.List layouts) { + float max = 0f; + for (GroupSetting.OptionLayout opt : layouts) max = Math.max(max, opt.x() - m(6f, 6f) + opt.w()); + return max + m(0.5f, 0.5f); + } + + private static float optionContainerHeightMode(ModeSetting.UiState ui) { + float max = m(10f, 10f); + for (ModeSetting.OptionLayout opt : ui.optionLayouts) max = Math.max(max, opt.y() - m(19f, 9.7f) + m(16f, 8f)); + return max; + } + + private static float optionContainerHeightGroup(GroupSetting.UiState ui) { + float max = m(10f, 10f); + for (GroupSetting.OptionLayout opt : ui.optionLayouts) max = Math.max(max, opt.y() - m(19f, 9.7f) + m(16f, 8f)); + return max; + } + + private static void renderOptionContainer(float x, float y, float w, float h, boolean active) { + float alpha = active ? 0.22f : 0.12f; + if (active) { + UnifiedSettingsSkin.drawAccent(x, y, Math.max(1f, w), Math.max(m(14f, 10f), h), m(3f, 3f), alpha); + } else { + UnifiedSettingsSkin.drawSurface(x, y, Math.max(1f, w), Math.max(m(14f, 10f), h), m(3f, 3f), 0.34f); + } + } + + static void render(TextSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + TextSetting.UiState ui = setting.ui(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float labelSize = m(15.5f, 6.3f); + float fieldSize = m(14f, 6.1f); + float fx = x + m(5f, 5f); + float fy = y + m(23f, 11.5f); + float fw = Math.max(1f, w - m(10f, 10f)); + float fh = m(20f, 13f); + String value = setting.getEditorText(); + ui.fieldX = fx; + ui.fieldY = fy; + ui.fieldW = fw; + ui.fieldH = fh; + + boolean hover = UnifiedSettingsSkin.inside(mx, my, fx, fy, fw, fh); + float dt = AnimationUtility.deltaTime(); + ui.hoverAnim = AnimationUtility.approach(ui.hoverAnim, hover ? 1f : 0f, dt, 13f); + ui.focusAnim = AnimationUtility.approach(ui.focusAnim, ui.editing ? 1f : 0f, dt, 12f); + ui.errorAnim = AnimationUtility.approach(ui.errorAnim, 0f, dt, 7f); + if (ui.editing) ui.cursorBlink += dt * 2.2f; + else ui.cursorBlink = 0f; + + ClickGuiRenderer.drawText(font, UnifiedSettingsSkin.fit(font, setting.getDisplayName(), labelSize, fw), fx, y + m(5f, 2.5f), labelSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + float fieldAnim = Math.max(ui.hoverAnim * 0.65f, ui.focusAnim); + int bg = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, fieldAnim), 56 + Math.round(fieldAnim * 42f)); + int stroke = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 58), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 155), ui.focusAnim); + stroke = UnifiedSettingsSkin.mix(stroke, 0xFFFF5F66, ui.errorAnim); + if (UnifiedSettingsSkin.SURFACE_GRADIENT_ENABLED) { + UnifiedSettingsSkin.drawSurface(fx, fy, fw, fh, m(5f, 3.5f), 0.55f + fieldAnim * 0.32f); + } else { + ClickGuiRenderer.drawRoundedRect(fx, fy, fw, fh, m(5f, 3.5f), bg); + } + ClickGuiRenderer.drawRoundedRectStroke(fx, fy, fw, fh, m(5f, 3.5f), m(0.7f, 0.45f), stroke); + + String text = ui.editing ? ui.editBuffer : (value == null ? "" : value); + String placeholder = "text..."; + float pad = m(6f, 4f); + float textX = fx + pad; + float textY = fy + (fh - UnifiedSettingsSkin.textHeight(font, fieldSize)) * 0.5f - m(0.2f, 0.15f); + float clipW = Math.max(1f, fw - pad * 2f); + if (ui.editing) ensureTextCursorVisible(ui, font, ui.editBuffer, fieldSize, clipW); + else ui.textScroll = 0f; + float drawX = textX - ui.textScroll; + ui.textX = drawX; + ui.textY = textY; + ui.textSize = fieldSize; + boolean clip = ScissorFunction.pushRaw(textX, fy, clipW, fh); + if (ui.editing) { + renderInlineSelection(font, ui.editBuffer, textSelStart(ui), textSelEnd(ui), drawX, textY, fieldSize, fh, fy); + ClickGuiRenderer.drawText(font, ui.editBuffer, drawX, textY, fieldSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + if (((int) (ui.cursorBlink * 2f) & 1) == 0) { + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + float cx = drawX + UnifiedSettingsSkin.textWidth(font, ui.editBuffer.substring(0, cursor), fieldSize) + m(0.5f, 0.3f); + ClickGuiRenderer.drawRect(cx, fy + m(3f, 2f), m(0.65f, 0.4f), fh - m(6f, 4f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_PRIMARY, 205)); + } + } else { + boolean empty = text.isBlank(); + String draw = empty ? placeholder : UnifiedSettingsSkin.fit(font, text, fieldSize, fw - pad * 2f); + ClickGuiRenderer.drawText(font, draw, textX, textY, fieldSize, empty ? UnifiedSettingsSkin.TEXT_FAINT : UnifiedSettingsSkin.TEXT_PRIMARY, false); + } + if (clip) ScissorFunction.pop(); + } + + // ------------------------------------------------------------ + // Text / TextList + // ------------------------------------------------------------ + + static void mouseClicked(TextSetting setting, double mx, double my, int button) { + if (button != 0) return; + TextSetting.UiState ui = setting.ui(); + if (UnifiedSettingsSkin.inside(mx, my, ui.fieldX, ui.fieldY, ui.fieldW, ui.fieldH)) { + beginTextEdit(setting); + setTextCursorFromMouse(setting, (float) mx, false); + } else if (ui.editing) { + commitTextEdit(setting, true); + } + } + + static void mouseClickedOutside(TextSetting setting, double mx, double my, int button) { + if (button != 0) return; + if (setting.ui().editing) commitTextEdit(setting, true); + } + + static boolean keyPressed(TextSetting setting, int keyCode, int scanCode, int modifiers) { + TextSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (ctrl && keyCode == GLFW.GLFW_KEY_A) { + selectAllText(ui); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_C) { + ClipboardUtil.copy(selectedOrAllText(ui)); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_V) { + insertTextText(setting, ClipboardUtil.get()); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_X) { + ClipboardUtil.copy(selectedOrAllText(ui)); + deleteTextSelection(ui); + return true; + } + switch (keyCode) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> { + commitTextEdit(setting, true); + return true; + } + case GLFW.GLFW_KEY_ESCAPE -> { + cancelTextEdit(setting); + return true; + } + case GLFW.GLFW_KEY_BACKSPACE -> { + if (!deleteTextSelection(ui) && ui.editCursor > 0) { + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor - 1) + ui.editBuffer.substring(ui.editCursor); + ui.editCursor--; + } + return true; + } + case GLFW.GLFW_KEY_DELETE -> { + if (!deleteTextSelection(ui) && ui.editCursor < ui.editBuffer.length()) + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor) + ui.editBuffer.substring(ui.editCursor + 1); + return true; + } + case GLFW.GLFW_KEY_LEFT -> { + setTextCursor(ui, ui.editCursor - 1, shift); + return true; + } + case GLFW.GLFW_KEY_RIGHT -> { + setTextCursor(ui, ui.editCursor + 1, shift); + return true; + } + case GLFW.GLFW_KEY_HOME -> { + setTextCursor(ui, 0, shift); + return true; + } + case GLFW.GLFW_KEY_END -> { + setTextCursor(ui, ui.editBuffer.length(), shift); + return true; + } + } + return true; + } + + static boolean charTyped(TextSetting setting, char chr, int modifiers) { + TextSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + if (chr == '\n' || chr == '\r') return true; + insertTextText(setting, String.valueOf(chr)); + return true; + } + + static float getHeight(TextSetting setting) { + return m(50f, 28f); + } + + private static void beginTextEdit(TextSetting setting) { + TextSetting.UiState ui = setting.ui(); + if (!ui.editing) { + ui.editBuffer = setting.getEditorText(); + ui.editCursor = ui.editBuffer.length(); + ui.editSelection.clear(); + ui.errorAnim = 0f; + } + ui.editing = true; + ui.cursorBlink = 0f; + } + + private static void cancelTextEdit(TextSetting setting) { + TextSetting.UiState ui = setting.ui(); + ui.editing = false; + ui.editBuffer = setting.getEditorText(); + ui.editCursor = ui.editBuffer.length(); + ui.textScroll = 0f; + ui.editSelection.clear(); + ui.errorAnim = 0f; + } + + private static void commitTextEdit(TextSetting setting, boolean save) { + TextSetting.UiState ui = setting.ui(); + setting.applyEditorText(ui.editBuffer == null ? "" : ui.editBuffer); + ui.editing = false; + ui.textScroll = 0f; + ui.editSelection.clear(); + if (!save && setting.getParent() != null) setting.getParent().saveConfig(); + } + + private static void setTextCursorFromMouse(TextSetting setting, float mx, boolean shift) { + TextSetting.UiState ui = setting.ui(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + int cursor = caretFromText(font, ui.editBuffer == null ? "" : ui.editBuffer, ui.textSize <= 0f ? m(14f, 6.1f) : ui.textSize, ui.textX, mx); + setTextCursor(ui, cursor, shift); + } + + private static void setTextCursor(TextSetting.UiState ui, int pos, boolean shift) { + int next = Math.max(0, Math.min(ui.editBuffer == null ? 0 : ui.editBuffer.length(), pos)); + if (shift) { + if (!ui.editSelection.hasRange()) ui.editSelection.begin(0, ui.editCursor); + ui.editSelection.updateCaret(0, next); + } else { + ui.editSelection.clear(); + } + ui.editCursor = next; + ui.cursorBlink = 0f; + } + + private static void insertTextText(TextSetting setting, String text) { + TextSetting.UiState ui = setting.ui(); + if (text == null || text.isEmpty()) return; + deleteTextSelection(ui); + String clean = text.replace("\r", "").replace("\n", " "); + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + ui.editBuffer = ui.editBuffer.substring(0, cursor) + clean + ui.editBuffer.substring(cursor); + ui.editCursor = cursor + clean.length(); + ui.editSelection.clear(); + } + + private static void ensureTextCursorVisible(TextSetting.UiState ui, TextRenderer font, String text, float size, float visibleW) { + String safe = text == null ? "" : text; + int cursor = Math.max(0, Math.min(ui.editCursor, safe.length())); + float textW = UnifiedSettingsSkin.textWidth(font, safe, size); + float maxScroll = Math.max(0f, textW - visibleW); + float caretX = UnifiedSettingsSkin.textWidth(font, safe.substring(0, cursor), size); + float margin = Math.min(m(8f, 4f), visibleW * 0.35f); + + if (caretX - ui.textScroll > visibleW - margin) { + ui.textScroll = caretX - visibleW + margin; + } else if (caretX - ui.textScroll < margin) { + ui.textScroll = caretX - margin; + } + ui.textScroll = Math.max(0f, Math.min(maxScroll, ui.textScroll)); + } + + private static void selectAllText(TextSetting.UiState ui) { + ui.editSelection.begin(0, 0); + ui.editSelection.updateCaret(0, ui.editBuffer == null ? 0 : ui.editBuffer.length()); + ui.editCursor = ui.editBuffer == null ? 0 : ui.editBuffer.length(); + } + + private static String selectedOrAllText(TextSetting.UiState ui) { + if (hasTextSelection(ui)) return ui.editBuffer.substring(textSelStart(ui), textSelEnd(ui)); + return ui.editBuffer == null ? "" : ui.editBuffer; + } + + private static boolean hasTextSelection(TextSetting.UiState ui) { + return ui.editSelection.hasRange(); + } + + private static int textSelStart(TextSetting.UiState ui) { + return hasTextSelection(ui) ? Math.max(0, Math.min(ui.editSelection.start(), ui.editBuffer.length())) : -1; + } + + private static int textSelEnd(TextSetting.UiState ui) { + return hasTextSelection(ui) ? Math.max(0, Math.min(ui.editSelection.end(), ui.editBuffer.length())) : -1; + } + + private static boolean deleteTextSelection(TextSetting.UiState ui) { + if (!hasTextSelection(ui)) return false; + int start = textSelStart(ui); + int end = textSelEnd(ui); + ui.editBuffer = ui.editBuffer.substring(0, start) + ui.editBuffer.substring(end); + ui.editCursor = start; + ui.editSelection.clear(); + return true; + } + + static void render(TextListSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + TextListSetting.UiState ui = setting.ui(); + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + + if (isInlineTextList(setting)) { + renderInlineTextList(setting, x, y, w, mx, my); + return; + } + + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float labelSize = m(15.5f, 6.4f); + float small = m(11.5f, 5.7f); + float bh = m(18f, 13f); + float editW = Math.max(m(36f, 28f), UnifiedSettingsSkin.textWidth(font, "Edit", small) + m(12f, 12f)); + float labelX = x + m(5f, 5f); + float labelMax = Math.max(m(50f, 34f), w - editW - m(22f, 16f)); + java.util.List labelLines = ClickGuiRenderer.wrapText(font, setting.getDisplayName(), labelSize, labelMax, 2); + if (labelLines.isEmpty()) labelLines = java.util.List.of(setting.getDisplayName()); + float lineH = UnifiedSettingsSkin.textHeight(font, labelSize); + float lineGap = m(2f, 0.7f); + float textBlockH = labelLines.size() * lineH + Math.max(0, labelLines.size() - 1) * lineGap; + float rowH = Math.max(m(44f, 24f), textBlockH + m(14f, 8f)); + float bx = x + w - m(9f, 9f) - editW; + float by = y + (rowH - bh) * 0.5f; + ui.lastButtonX = bx; + ui.lastButtonY = by; + ui.lastButtonW = editW; + ui.lastButtonH = bh; + boolean hover = UnifiedSettingsSkin.inside(mx, my, bx, by, editW, bh); + ui.moduleHoverAnim = AnimationUtility.approach(ui.moduleHoverAnim, hover ? 1f : 0f, AnimationUtility.deltaTime(), 13f); + float labelY = y + (rowH - textBlockH) * 0.5f; + for (int i = 0; i < labelLines.size(); i++) { + ClickGuiRenderer.drawText(font, labelLines.get(i), labelX, labelY + i * (lineH + lineGap), labelSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + } + renderChipButton(bx, by, editW, bh, "Edit", ui.moduleHoverAnim, false); + } + + private static void renderInlineTextList(TextListSetting setting, float x, float y, float w, float mx, float my) { + TextListSetting.UiState ui = setting.ui(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float labelSize = m(15.5f, 6.3f); + float fieldSize = m(14f, 6.1f); + float rowH = inlineTextListHeight(); + String label = setting.getDisplayName(); + float labelY = y + m(5f, 2.5f); + float fieldY = y + m(23f, 11.5f); + float fieldH = m(20f, 13f); + float fieldX = x + m(5f, 5f); + float fieldW = Math.max(1f, w - m(10f, 10f)); + ui.fieldX = fieldX; + ui.fieldY = fieldY; + ui.fieldW = fieldW; + ui.fieldH = fieldH; + boolean hover = UnifiedSettingsSkin.inside(mx, my, fieldX, fieldY, fieldW, fieldH); + float dt = AnimationUtility.deltaTime(); + ui.fieldHoverAnim = AnimationUtility.approach(ui.fieldHoverAnim, hover ? 1f : 0f, dt, 13f); + ui.fieldFocusAnim = AnimationUtility.approach(ui.fieldFocusAnim, ui.editing ? 1f : 0f, dt, 12f); + ui.fieldErrorAnim = AnimationUtility.approach(ui.fieldErrorAnim, 0f, dt, 7f); + if (ui.editing) ui.cursorBlink += dt * 2.2f; + else ui.cursorBlink = 0f; + + ClickGuiRenderer.drawText(font, UnifiedSettingsSkin.fit(font, label, labelSize, fieldW), fieldX, labelY, labelSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + float fieldAnim = Math.max(ui.fieldHoverAnim * 0.65f, ui.fieldFocusAnim); + int bg = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, fieldAnim), 56 + Math.round(fieldAnim * 42f)); + int stroke = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 58), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 155), ui.fieldFocusAnim); + stroke = UnifiedSettingsSkin.mix(stroke, 0xFFFF5F66, ui.fieldErrorAnim); + if (UnifiedSettingsSkin.SURFACE_GRADIENT_ENABLED) { + UnifiedSettingsSkin.drawSurface(fieldX, fieldY, fieldW, fieldH, m(5f, 3.5f), 0.55f + fieldAnim * 0.32f); + } else { + ClickGuiRenderer.drawRoundedRect(fieldX, fieldY, fieldW, fieldH, m(5f, 3.5f), bg); + } + ClickGuiRenderer.drawRoundedRectStroke(fieldX, fieldY, fieldW, fieldH, m(5f, 3.5f), m(0.7f, 0.45f), stroke); + + String current = firstTextListEntry(setting); + String text = ui.editing ? ui.editBuffer : current; + if (text == null) text = ""; + String placeholder = "value..."; + float pad = m(6f, 4f); + float textX = fieldX + pad; + float textY = fieldY + (fieldH - UnifiedSettingsSkin.textHeight(font, fieldSize)) * 0.5f - m(0.2f, 0.15f); + boolean clip = ScissorFunction.pushRaw(textX, fieldY, Math.max(1f, fieldW - pad * 2f), fieldH); + if (ui.editing) { + renderInlineSelection(font, ui.editBuffer, textListSelStart(ui), textListSelEnd(ui), textX, textY, fieldSize, fieldH, fieldY); + ClickGuiRenderer.drawText(font, ui.editBuffer, textX, textY, fieldSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + if (((int) (ui.cursorBlink * 2f) & 1) == 0) { + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + float cx = textX + UnifiedSettingsSkin.textWidth(font, ui.editBuffer.substring(0, cursor), fieldSize) + m(0.5f, 0.3f); + ClickGuiRenderer.drawRect(cx, fieldY + m(3f, 2f), m(0.65f, 0.4f), fieldH - m(6f, 4f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_PRIMARY, 205)); + } + } else { + boolean empty = text.isBlank(); + String draw = empty ? placeholder : UnifiedSettingsSkin.fit(font, text, fieldSize, fieldW - pad * 2f); + ClickGuiRenderer.drawText(font, draw, textX, textY, fieldSize, empty ? UnifiedSettingsSkin.TEXT_FAINT : UnifiedSettingsSkin.TEXT_PRIMARY, false); + } + if (clip) ScissorFunction.pop(); + } + + static void mouseClicked(TextListSetting setting, double mx, double my, int button) { + if (button != 0) return; + TextListSetting.UiState ui = setting.ui(); + if (isInlineTextList(setting)) { + if (UnifiedSettingsSkin.inside(mx, my, ui.fieldX, ui.fieldY, ui.fieldW, ui.fieldH)) { + beginTextListEdit(setting); + setTextListCursorFromMouse(setting, (float) mx, false); + } else if (ui.editing) { + commitTextListEdit(setting, true); + } + return; + } + if (!UnifiedSettingsSkin.inside(mx, my, ui.lastButtonX, ui.lastButtonY, ui.lastButtonW, ui.lastButtonH)) return; + if (setting.getPickerMode() == TextListSetting.PickerMode.TEXT) { + ClickGuiRenderer.openTextEditor(setting, setting.getDisplayName(), setting.getEditorText(), ui.lastButtonX, ui.lastButtonY, ui.lastButtonW); + } else { + ClickGuiRenderer.openPicker(setting, setting.getDisplayName(), setting.getPickerMode()); + } + } + + static void mouseClickedOutside(TextListSetting setting, double mx, double my, int button) { + if (button != 0 || !isInlineTextList(setting)) return; + if (setting.ui().editing) commitTextListEdit(setting, true); + } + + static boolean keyPressed(TextListSetting setting, int keyCode, int scanCode, int modifiers) { + if (!isInlineTextList(setting)) return false; + TextListSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + boolean shift = (modifiers & GLFW.GLFW_MOD_SHIFT) != 0; + if (ctrl && keyCode == GLFW.GLFW_KEY_A) { + selectAllTextList(ui); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_C) { + ClipboardUtil.copy(selectedOrAllTextList(ui)); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_V) { + insertTextListText(setting, ClipboardUtil.get()); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_X) { + ClipboardUtil.copy(selectedOrAllTextList(ui)); + deleteTextListSelection(ui); + return true; + } + switch (keyCode) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> { + commitTextListEdit(setting, true); + return true; + } + case GLFW.GLFW_KEY_ESCAPE -> { + cancelTextListEdit(setting); + return true; + } + case GLFW.GLFW_KEY_BACKSPACE -> { + if (!deleteTextListSelection(ui) && ui.editCursor > 0) { + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor - 1) + ui.editBuffer.substring(ui.editCursor); + ui.editCursor--; + } + return true; + } + case GLFW.GLFW_KEY_DELETE -> { + if (!deleteTextListSelection(ui) && ui.editCursor < ui.editBuffer.length()) + ui.editBuffer = ui.editBuffer.substring(0, ui.editCursor) + ui.editBuffer.substring(ui.editCursor + 1); + return true; + } + case GLFW.GLFW_KEY_LEFT -> { + setTextListCursor(ui, ui.editCursor - 1, shift); + return true; + } + case GLFW.GLFW_KEY_RIGHT -> { + setTextListCursor(ui, ui.editCursor + 1, shift); + return true; + } + case GLFW.GLFW_KEY_HOME -> { + setTextListCursor(ui, 0, shift); + return true; + } + case GLFW.GLFW_KEY_END -> { + setTextListCursor(ui, ui.editBuffer.length(), shift); + return true; + } + } + return true; + } + + static boolean charTyped(TextListSetting setting, char chr, int modifiers) { + if (!isInlineTextList(setting)) return false; + TextListSetting.UiState ui = setting.ui(); + if (!ui.editing) return false; + if (chr == '\n' || chr == '\r') return true; + insertTextListText(setting, String.valueOf(chr)); + return true; + } + + static float getHeight(TextListSetting setting) { + return isInlineTextList(setting) ? inlineTextListHeight() : textListButtonHeight(setting); + } + + private static boolean isInlineTextList(TextListSetting setting) { + return setting.getPickerMode() == TextListSetting.PickerMode.TEXT && setting.isSingleLine(); + } + + private static float inlineTextListHeight() { + return m(50f, 28f); + } + + private static String firstTextListEntry(TextListSetting setting) { + Set set = setting.getValueSet(); + if (set == null || set.isEmpty()) return ""; + return set.iterator().next(); + } + + private static float textListButtonHeight(TextListSetting setting) { + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float width = setting.ui().lastW > 0f ? setting.ui().lastW : m(170f, 115f); + float labelSize = m(15.5f, 6.4f); + float small = m(11.5f, 5.7f); + float editW = Math.max(m(36f, 28f), UnifiedSettingsSkin.textWidth(font, "Edit", small) + m(12f, 12f)); + float labelMax = Math.max(m(50f, 34f), width - editW - m(22f, 16f)); + java.util.List labelLines = ClickGuiRenderer.wrapText(font, setting.getDisplayName(), labelSize, labelMax, 2); + int lines = Math.max(1, labelLines.size()); + float lineH = UnifiedSettingsSkin.textHeight(font, labelSize); + float textBlockH = lines * lineH + Math.max(0, lines - 1) * m(2f, 0.7f); + return Math.max(m(44f, 24f), textBlockH + m(14f, 8f)); + } + + private static void beginTextListEdit(TextListSetting setting) { + TextListSetting.UiState ui = setting.ui(); + if (!ui.editing) { + ui.editBuffer = firstTextListEntry(setting); + ui.editCursor = ui.editBuffer.length(); + ui.editSelection.clear(); + ui.fieldErrorAnim = 0f; + } + ui.editing = true; + ui.cursorBlink = 0f; + } + + private static void cancelTextListEdit(TextListSetting setting) { + TextListSetting.UiState ui = setting.ui(); + ui.editing = false; + ui.editBuffer = firstTextListEntry(setting); + ui.editCursor = ui.editBuffer.length(); + ui.editSelection.clear(); + ui.fieldErrorAnim = 0f; + } + + private static void commitTextListEdit(TextListSetting setting, boolean save) { + TextListSetting.UiState ui = setting.ui(); + String raw = ui.editBuffer == null ? "" : ui.editBuffer.trim(); + java.util.LinkedHashSet next = new java.util.LinkedHashSet<>(); + if (!raw.isEmpty()) next.add(setting.normalizeEntry(raw)); + setting.value().set(next); + ui.editing = false; + ui.editSelection.clear(); + if (save && setting.getParent() != null) setting.getParent().saveConfig(); + } + + private static void setTextListCursorFromMouse(TextListSetting setting, float mx, boolean shift) { + TextListSetting.UiState ui = setting.ui(); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(14f, 6.1f); + int cursor = caretFromText(font, ui.editBuffer == null ? "" : ui.editBuffer, size, ui.fieldX + m(6f, 4f), mx); + setTextListCursor(ui, cursor, shift); + } + + private static void setTextListCursor(TextListSetting.UiState ui, int pos, boolean shift) { + int next = Math.max(0, Math.min(ui.editBuffer == null ? 0 : ui.editBuffer.length(), pos)); + if (shift) { + if (!ui.editSelection.hasRange()) ui.editSelection.begin(0, ui.editCursor); + ui.editSelection.updateCaret(0, next); + } else { + ui.editSelection.clear(); + } + ui.editCursor = next; + ui.cursorBlink = 0f; + } + + private static void insertTextListText(TextListSetting setting, String text) { + TextListSetting.UiState ui = setting.ui(); + if (text == null || text.isEmpty()) return; + deleteTextListSelection(ui); + String clean = text.replace("\r", "").replace("\n", " "); + int cursor = Math.max(0, Math.min(ui.editCursor, ui.editBuffer.length())); + ui.editBuffer = ui.editBuffer.substring(0, cursor) + clean + ui.editBuffer.substring(cursor); + ui.editCursor = cursor + clean.length(); + ui.editSelection.clear(); + } + + private static void selectAllTextList(TextListSetting.UiState ui) { + ui.editSelection.begin(0, 0); + ui.editSelection.updateCaret(0, ui.editBuffer == null ? 0 : ui.editBuffer.length()); + ui.editCursor = ui.editBuffer == null ? 0 : ui.editBuffer.length(); + } + + private static String selectedOrAllTextList(TextListSetting.UiState ui) { + if (hasTextListSelection(ui)) return ui.editBuffer.substring(textListSelStart(ui), textListSelEnd(ui)); + return ui.editBuffer == null ? "" : ui.editBuffer; + } + + private static boolean hasTextListSelection(TextListSetting.UiState ui) { + return ui.editSelection.hasRange(); + } + + private static int textListSelStart(TextListSetting.UiState ui) { + return hasTextListSelection(ui) ? Math.max(0, Math.min(ui.editSelection.start(), ui.editBuffer.length())) : -1; + } + + private static int textListSelEnd(TextListSetting.UiState ui) { + return hasTextListSelection(ui) ? Math.max(0, Math.min(ui.editSelection.end(), ui.editBuffer.length())) : -1; + } + + private static boolean deleteTextListSelection(TextListSetting.UiState ui) { + if (!hasTextListSelection(ui)) return false; + int start = textListSelStart(ui); + int end = textListSelEnd(ui); + ui.editBuffer = ui.editBuffer.substring(0, start) + ui.editBuffer.substring(end); + ui.editCursor = start; + ui.editSelection.clear(); + return true; + } + + static void render(KeyBindSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + KeyBindSetting.UiState ui = setting.ui(); + String pending = ClickGuiRenderer.getPendingBindDisplay(); + String display = ui.waiting ? (pending == null ? "..." : pending) : setting.getValue().get(); + renderBindBase(setting.getDisplayName(), display, null, ui.waiting, x, y, w, mx, my, ui); + } + + // ------------------------------------------------------------ + // Key binds + // ------------------------------------------------------------ + + static void mouseClicked(KeyBindSetting setting, double mx, double my, int button) { + if (button != 0) return; + KeyBindSetting.UiState ui = setting.ui(); + if (!UnifiedSettingsSkin.inside(mx, my, ui.lastBx, ui.lastBy, ui.lastBw, ui.lastBh)) return; + ui.waiting = true; + ClickGuiRenderer.beginKeyBind(setting); + } + + static float getHeight(KeyBindSetting setting) { + return bindHeight(setting.ui().lastW, setting.getDisplayName(), setting.getValue().get(), null); + } + + static void render(FunctionBindSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + FunctionBindSetting.UiState ui = setting.ui(); + String pending = ClickGuiRenderer.getPendingBindDisplay(); + String display = ui.waiting ? (pending == null ? "..." : pending) : setting.get(); + String modeText = setting.mode() == BindMode.HOLD ? "Hold" : "Press"; + renderBindBase(setting.getDisplayName(), display, modeText, ui.waiting, x, y, w, mx, my, ui); + } + + static void mouseClicked(FunctionBindSetting setting, double mx, double my, int button) { + if (button != 0) return; + FunctionBindSetting.UiState ui = setting.ui(); + if (!UnifiedSettingsSkin.inside(mx, my, ui.lastBx, ui.lastBy, ui.lastBw, ui.lastBh)) return; + ui.waiting = true; + ClickGuiRenderer.beginKeyBind(setting); + } + + static float getHeight(FunctionBindSetting setting) { + String modeText = setting.mode() == BindMode.HOLD ? "Hold" : "Press"; + return bindHeight(setting.ui().lastW, setting.getDisplayName(), setting.get(), modeText); + } + + private static void renderBindBase(String label, String display, String modeText, boolean waiting, float x, float y, float w, float mx, float my, Object uiObj) { + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 7.0f); + float small = m(11.5f, 5.7f); + String displayText = display == null || display.isBlank() ? "NONE" : display; + boolean none = displayText.equalsIgnoreCase("NONE"); + float pad = m(6f, 5f); + float gap = m(4f, 4f); + float chipPad = m(7f, 5f); + float chipH = m(22f, 13.5f); + float minLabelW = m(72f, 38f); + float modeW = modeText == null ? 0f : Math.max(m(36f, 25f), UnifiedSettingsSkin.textWidth(font, modeText, small) + m(10f, 8f)); + float modeBlockW = modeW > 0f ? modeW + gap : 0f; + float rawTextW = UnifiedSettingsSkin.textWidth(font, displayText, size); + float wantedChipW = rawTextW + chipPad * 2f; + + float sameLineMaxChipW = Math.max(m(48f, 30f), w - pad * 3f - modeBlockW - minLabelW); + boolean nextLine = sameLineMaxChipW < m(48f, 30f); + float maxChipW = nextLine + ? Math.max(m(48f, 30f), w - pad * 2f - modeBlockW) + : sameLineMaxChipW; + float chipW = Math.max(m(48f, 30f), Math.min(wantedChipW, maxChipW)); + float labelMax = nextLine ? w - pad * 2f : Math.max(1f, w - pad * 3f - modeBlockW - chipW); + if (!nextLine && labelMax < minLabelW) { + nextLine = true; + maxChipW = Math.max(m(48f, 30f), w - pad * 2f - modeBlockW); + chipW = Math.max(m(48f, 30f), Math.min(wantedChipW, maxChipW)); + labelMax = w - pad * 2f; + } + + float rowH = nextLine ? m(48f, 31f) : m(38f, 22f); + float cardX = x + m(2f, 2f); + float cardY = y + m(3f, 2f); + float cardW = Math.max(1f, w - m(4f, 4f)); + float cardH = Math.max(m(30f, 18f), rowH - m(6f, 4f)); + float cardRadius = m(8f, 5f); + boolean hover = UnifiedSettingsSkin.inside(mx, my, cardX, cardY, cardW, cardH); + float hoverAnim = bindHoverAnim(uiObj, hover); + float waitAnim = bindWaitAnim(uiObj, waiting); + + UnifiedSettingsSkin.drawCard(cardX, cardY, cardW, cardH, cardRadius, 0.38f + hoverAnim * 0.24f + waitAnim * 0.10f); + if (hoverAnim > 0.001f || waitAnim > 0.001f) { + int strokeA = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(hoverAnim * 72f)); + int strokeB = UnifiedSettingsSkin.accentGradientStart(waitAnim * 0.70f); + ClickGuiRenderer.drawRoundedRectStroke(cardX, cardY, cardW, cardH, cardRadius, m(0.65f, 0.42f), UnifiedSettingsSkin.mix(strokeA, strokeB, Math.max(waitAnim, hoverAnim * 0.28f))); + } + + float labelY = y + (nextLine ? m(7f, 4f) : (rowH - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f); + float chipY = nextLine ? y + m(27f, 16f) : y + (rowH - chipH) * 0.5f; + float chipX; + float modeX = 0f; + float modeY = chipY; + if (nextLine) { + chipX = cardX + pad; + if (modeW > 0f) modeX = x + w - pad - modeW; + } else { + chipX = x + w - pad - chipW; + if (modeW > 0f) modeX = chipX - gap - modeW; + } + + int labelColor = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, 0.72f + hoverAnim * 0.18f + waitAnim * 0.10f); + ClickGuiRenderer.drawText(font, UnifiedSettingsSkin.fit(font, label, size, Math.max(1f, labelMax)), cardX + pad, labelY, size, labelColor, false); + if (modeW > 0f) renderSmallModeChip(modeX, modeY, modeW, chipH, modeText, small, hoverAnim, waitAnim); + + float chipInset = m(0.8f, 0.45f); + float chipDrawX = chipX + chipInset; + float chipDrawY = chipY + chipInset; + float chipDrawW = Math.max(1f, chipW - chipInset * 2f); + float chipDrawH = Math.max(1f, chipH - chipInset * 2f); + float chipRadius = Math.max(0f, m(6f, 4f) - chipInset); + if (waiting) { + UnifiedSettingsSkin.drawAccent(chipDrawX, chipDrawY, chipDrawW, chipDrawH, chipRadius, 0.62f + hoverAnim * 0.18f); + ClickGuiRenderer.drawRoundedRectStrokeGradient(chipDrawX, chipDrawY, chipDrawW, chipDrawH, chipRadius, m(0.75f, 0.45f), UnifiedSettingsSkin.accentGradientStart(0.92f), UnifiedSettingsSkin.accentGradientEnd(0.92f), UnifiedSettingsSkin.ACCENT_GRADIENT_ANGLE); + } else { + UnifiedSettingsSkin.drawSurface(chipDrawX, chipDrawY, chipDrawW, chipDrawH, chipRadius, none ? 0.30f + hoverAnim * 0.12f : 0.46f + hoverAnim * 0.16f); + int chipStroke = none + ? UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(34f + hoverAnim * 36f)) + : UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, Math.round(48f + hoverAnim * 54f)); + ClickGuiRenderer.drawRoundedRectStroke(chipDrawX, chipDrawY, chipDrawW, chipDrawH, chipRadius, m(0.65f, 0.42f), chipStroke); + } + + float contentX = chipX + chipPad; + float contentW = Math.max(1f, chipW - chipPad * 2f); + float textX = contentX; + if (rawTextW <= contentW) { + textX = chipX + (chipW - rawTextW) * 0.5f; + } + float textY = chipY + (chipH - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f; + boolean textClip = ScissorFunction.pushRaw(contentX, chipY, contentW, chipH); + int valueColor = waiting + ? 0xFFFFFFFF + : UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, none ? 0.20f + hoverAnim * 0.12f : 0.82f + hoverAnim * 0.12f); + if (rawTextW > contentW + 0.5f) { + drawBindMarquee(font, displayText, contentX, textY, rawTextW, contentW, size, valueColor); + } else { + ClickGuiRenderer.drawText(font, displayText, textX, textY, size, valueColor, false); + } + if (textClip) ScissorFunction.pop(); + + if (uiObj instanceof KeyBindSetting.UiState ui) { + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + ui.lastH = rowH; + ui.lastBx = chipX; + ui.lastBy = chipY; + ui.lastBw = chipW; + ui.lastBh = chipH; + } else if (uiObj instanceof FunctionBindSetting.UiState ui) { + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + ui.lastH = rowH; + ui.lastBx = chipX; + ui.lastBy = chipY; + ui.lastBw = chipW; + ui.lastBh = chipH; + } + } + + private static float bindHoverAnim(Object uiObj, boolean hover) { + float dt = AnimationUtility.deltaTime(); + if (uiObj instanceof KeyBindSetting.UiState ui) { + ui.hoverAnim = AnimationUtility.approach(ui.hoverAnim, hover ? 1f : 0f, dt, 12f); + return ui.hoverAnim; + } + if (uiObj instanceof FunctionBindSetting.UiState ui) { + ui.hoverAnim = AnimationUtility.approach(ui.hoverAnim, hover ? 1f : 0f, dt, 12f); + return ui.hoverAnim; + } + return hover ? 1f : 0f; + } + + private static float bindWaitAnim(Object uiObj, boolean waiting) { + float dt = AnimationUtility.deltaTime(); + if (uiObj instanceof KeyBindSetting.UiState ui) { + ui.waitAnim = AnimationUtility.approach(ui.waitAnim, waiting ? 1f : 0f, dt, 14f); + return ui.waitAnim; + } + if (uiObj instanceof FunctionBindSetting.UiState ui) { + ui.waitAnim = AnimationUtility.approach(ui.waitAnim, waiting ? 1f : 0f, dt, 14f); + return ui.waitAnim; + } + return waiting ? 1f : 0f; + } + + private static void renderSmallModeChip(float x, float y, float w, float h, String text, float size, float hoverAnim, float waitAnim) { + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float inset = m(0.8f, 0.45f); + float dx = x + inset; + float dy = y + inset; + float dw = Math.max(1f, w - inset * 2f); + float dh = Math.max(1f, h - inset * 2f); + float radius = Math.max(0f, m(6f, 4f) - inset); + int bg = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, 0.45f + hoverAnim * 0.25f + waitAnim * 0.20f); + ClickGuiRenderer.drawRoundedRect(dx, dy, dw, dh, radius, bg); + ClickGuiRenderer.drawRoundedRectStroke(dx, dy, dw, dh, radius, m(0.55f, 0.35f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(34f + hoverAnim * 24f + waitAnim * 30f))); + String fitted = UnifiedSettingsSkin.fit(font, text, size, Math.max(1f, w - m(6f, 6f))); + float tw = UnifiedSettingsSkin.textWidth(font, fitted, size); + int color = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, waitAnim * 0.35f + hoverAnim * 0.15f); + ClickGuiRenderer.drawText(font, fitted, x + (w - tw) * 0.5f, y + (h - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f, size, color, false); + } + + private static float bindHeight(float lastW, String label, String display, String modeText) { + float w = lastW > 0f ? lastW : m(160f, 115f); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(17f, 7.0f); + float small = m(11.5f, 5.7f); + float pad = m(6f, 5f); + float gap = m(4f, 4f); + float chipPad = m(7f, 5f); + float minLabelW = m(72f, 38f); + float modeW = modeText == null ? 0f : Math.max(m(36f, 25f), UnifiedSettingsSkin.textWidth(font, modeText, small) + m(10f, 8f)); + float modeBlockW = modeW > 0f ? modeW + gap : 0f; + String displayText = display == null || display.isBlank() ? "NONE" : display; + float wantedChipW = UnifiedSettingsSkin.textWidth(font, displayText, size) + chipPad * 2f; + float sameLineMaxChipW = Math.max(m(48f, 30f), w - pad * 3f - modeBlockW - minLabelW); + boolean nextLine = sameLineMaxChipW < m(48f, 30f); + if (!nextLine) { + float chipW = Math.max(m(48f, 30f), Math.min(wantedChipW, sameLineMaxChipW)); + float labelMax = w - pad * 3f - modeBlockW - chipW; + nextLine = labelMax < minLabelW; + } + return nextLine ? m(48f, 31f) : m(38f, 22f); + } + + static void render(ColorSetting setting, float x, float y, float w, float mx, float my) { + UnifiedSettingsSkin.syncTheme(); + ColorSetting.UiState ui = setting.ui(); + ColorValue value = setting.value(); + boolean hasAlpha = value.supportsAlpha(); + ui.lastX = x; + ui.lastY = y; + ui.lastW = w; + if (!ui.hexFocused && !colorDragActive(ui)) syncColorFromValue(ui, value); + ui.rainbow = value.isRainbow(); + if (ui.rainbow) clearColorFocus(ui); + + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float labelSize = m(18f, 7f); + float previewW = m(34f, 20f); + float previewH = m(16f, 10f); + float previewX = x + w - previewW - m(5f, 5f); + float previewY = y + (colorHeaderH() - previewH) * 0.5f; + String label = UnifiedSettingsSkin.fit(font, setting.getDisplayName(), labelSize, Math.max(1f, previewX - x - m(12f, 12f))); + ClickGuiRenderer.drawText(font, label, x + m(7f, 7f), y + (colorHeaderH() - UnifiedSettingsSkin.textHeight(font, labelSize)) * 0.5f, labelSize, UnifiedSettingsSkin.TEXT_PRIMARY, false); + renderColorPreview(previewX, previewY, previewW, previewH, value.getArgb(), m(3f, 3f)); + ui.previewGlowAnim = AnimationUtility.approach(ui.previewGlowAnim, ui.editing ? 1f : UnifiedSettingsSkin.inside(mx, my, previewX, previewY, previewW, previewH) ? 0.55f : 0f, 0.20f); + + if (ui.expandAnim <= 0.001f) return; + layoutColor(ui, x, y, w, hasAlpha); + updateColorDrag(ui, value, mx, my, hasAlpha); + float reveal = colorReveal(ui); + float slide = ui.editing ? (1f - reveal) * m(8f, 8f) : (1f - reveal) * m(6f, 6f); + float clipY = y + colorHeaderH(); + float clipH = (colorExpandedHeight(ui, hasAlpha) + m(5f, 5f)) * reveal; + boolean clipped = clipH > 0.5f && ScissorFunction.pushRaw(x, clipY, w, clipH); + + float cx = ui.squareX; + float cy = ui.squareY + slide; + float containerX = cx - m(5f, 5f); + float containerY = cy - m(5f, 5f); + float containerW = x + w - m(5f, 5f) - containerX; + float containerH = colorExpandedHeight(ui, hasAlpha); + UnifiedSettingsSkin.drawSurface(containerX, containerY, containerW, containerH, m(6f, 6f), 0.90f); + + renderColorSquare(ui, cy); + renderHueBar(ui, slide); + if (hasAlpha) { + renderChecker(ui.alphaX, ui.alphaY + slide, ui.alphaW, ui.alphaH, m(3f, 3f), m(3f, 3f)); + renderAlphaBar(ui, slide); + } + if (ui.rgbExpandAnim > 0.001f) renderRgbBars(ui, hasAlpha, slide); + renderColorMarkers(ui, hasAlpha, slide); + renderColorPresets(ui, value, mx, my, slide); + renderHexInput(ui, value, mx, my, slide); + renderColorActions(ui, value, mx, my, slide); + if (clipped) ScissorFunction.pop(); + } + + // ------------------------------------------------------------ + // Color + // ------------------------------------------------------------ + + static void mouseClicked(ColorSetting setting, double mx, double my, int button) { + if (button != 0 && button != 1 && button != 2) return; + ColorSetting.UiState ui = setting.ui(); + ColorValue value = setting.value(); + boolean hasAlpha = value.supportsAlpha(); + float pw = m(34f, 20f); + float ph = m(16f, 10f); + float px = ui.lastX + ui.lastW - pw - m(5f, 5f); + float py = ui.lastY + (colorHeaderH() - ph) * 0.5f; + boolean previewHit = UnifiedSettingsSkin.inside(mx, my, px, py, pw, ph); + boolean headerHit = UnifiedSettingsSkin.inside(mx, my, ui.lastX, ui.lastY, ui.lastW, colorHeaderH()); + if (button == 0 && headerHit) { + ui.editing = !ui.editing; + clearColorFocus(ui); + return; + } + if (!ui.editing) return; + layoutColor(ui, ui.lastX, ui.lastY, ui.lastW, hasAlpha); + if (handleColorPresetClick(setting, value, ui, mx, my, button)) return; + if (button != 0) return; + if (UnifiedSettingsSkin.inside(mx, my, ui.hexX, ui.hexY, ui.hexW, ui.hexH)) { + beginHexEdit(ui, value); + setHexCursorFromMouse(ui, (float) mx, false); + return; + } + if (ui.hexFocused) { + if (!commitHexEdit(setting, value, ui, true)) return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.sliderX, ui.sliderY, ui.sliderW, ui.sliderH)) { + ui.showRgbBars = !ui.showRgbBars; + clearColorFocus(ui); + return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.rnbX, ui.rnbY, ui.rnbW, ui.rnbH)) { + value.set(value.isRainbow() ? value.rainbowFallbackHex() : value.toRainbowValue()); + syncColorFromValue(ui, value); + clearColorFocus(ui); + if (setting.getParent() != null) setting.getParent().saveConfig(); + return; + } + if (value.isRainbow()) return; + clearColorFocus(ui); + if (UnifiedSettingsSkin.inside(mx, my, ui.squareX, ui.squareY, ui.squareW, ui.squareH)) { + ui.sbFocused = true; + return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.hueX - m(2f, 2f), ui.hueY, ui.hueW + m(4f, 4f), ui.hueH)) { + ui.hFocused = true; + return; + } + if (hasAlpha && UnifiedSettingsSkin.inside(mx, my, ui.alphaX - m(2f, 2f), ui.alphaY, ui.alphaW + m(4f, 4f), ui.alphaH)) { + ui.aFocused = true; + return; + } + if (ui.showRgbBars && ui.rgbExpandAnim > 0.20f) { + if (UnifiedSettingsSkin.inside(mx, my, ui.rX, ui.rY, ui.rW, ui.rH)) { + ui.rFocused = true; + return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.gX, ui.gY, ui.gW, ui.gH)) { + ui.gFocused = true; + return; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.bX, ui.bY, ui.bW, ui.bH)) { + ui.bFocused = true; + return; + } + if (hasAlpha && UnifiedSettingsSkin.inside(mx, my, ui.aX, ui.aY, ui.aW, ui.aH)) ui.aRgbFocused = true; + } + } + + static void mouseReleased(ColorSetting setting, double mx, double my, int button) { + if (button != 0) return; + ColorSetting.UiState ui = setting.ui(); + boolean changed = ui.sbFocused || ui.hFocused || ui.aFocused || ui.rFocused || ui.gFocused || ui.bFocused || ui.aRgbFocused; + ui.presetsScrollbarDragging = false; + clearColorFocus(ui); + if (changed && setting.getParent() != null) setting.getParent().saveConfig(); + } + + static void mouseClickedOutside(ColorSetting setting, double mx, double my, int button) { + if (button != 0) return; + ColorSetting.UiState ui = setting.ui(); + if (ui.hexFocused) { + commitHexEdit(setting, setting.value(), ui, true); + } + ui.presetsScrollbarDragging = false; + clearColorFocus(ui); + } + + static boolean keyPressed(ColorSetting setting, int keyCode, int scanCode, int modifiers) { + ColorSetting.UiState ui = setting.ui(); + if (!ui.hexFocused) return false; + ColorValue value = setting.value(); + boolean ctrl = (modifiers & GLFW.GLFW_MOD_CONTROL) != 0; + if (ctrl && keyCode == GLFW.GLFW_KEY_A) { + selectAllHex(ui); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_C) { + ClipboardUtil.copy("#" + selectedOrAllHex(ui)); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_V) { + insertHexText(ui, ClipboardUtil.get()); + return true; + } + if (ctrl && keyCode == GLFW.GLFW_KEY_X) { + ClipboardUtil.copy("#" + selectedOrAllHex(ui)); + deleteHexSelection(ui); + return true; + } + switch (keyCode) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> { + commitHexEdit(setting, value, ui, true); + return true; + } + case GLFW.GLFW_KEY_ESCAPE -> { + cancelHexEdit(ui, value); + return true; + } + case GLFW.GLFW_KEY_BACKSPACE -> { + if (!deleteHexSelection(ui) && ui.hexCursor > 0) { + ui.hexBuffer = ui.hexBuffer.substring(0, ui.hexCursor - 1) + ui.hexBuffer.substring(ui.hexCursor); + ui.hexCursor--; + } + return true; + } + case GLFW.GLFW_KEY_DELETE -> { + if (!deleteHexSelection(ui) && ui.hexCursor < ui.hexBuffer.length()) + ui.hexBuffer = ui.hexBuffer.substring(0, ui.hexCursor) + ui.hexBuffer.substring(ui.hexCursor + 1); + return true; + } + case GLFW.GLFW_KEY_LEFT -> { + moveHexCursor(ui, -1, (modifiers & GLFW.GLFW_MOD_SHIFT) != 0); + return true; + } + case GLFW.GLFW_KEY_RIGHT -> { + moveHexCursor(ui, 1, (modifiers & GLFW.GLFW_MOD_SHIFT) != 0); + return true; + } + case GLFW.GLFW_KEY_HOME -> { + setHexCursor(ui, 0, (modifiers & GLFW.GLFW_MOD_SHIFT) != 0); + return true; + } + case GLFW.GLFW_KEY_END -> { + setHexCursor(ui, ui.hexBuffer.length(), (modifiers & GLFW.GLFW_MOD_SHIFT) != 0); + return true; + } + } + return true; + } + + static boolean charTyped(ColorSetting setting, char chr, int modifiers) { + ColorSetting.UiState ui = setting.ui(); + if (!ui.hexFocused) return false; + if (isHexChar(chr)) { + insertHexText(ui, String.valueOf(chr)); + } else if (chr == '#') { + // accepted as no-op so pasted/manual values with # don't flash error + } else { + ui.hexErrorAnim = 1f; + } + return true; + } + + static boolean mouseScrolled(ColorSetting setting, double mx, double my, double amount) { + ColorSetting.UiState ui = setting.ui(); + if (!ui.editing || !ui.presetsExpanded || ui.presetsMaxScroll <= 0.5f) return false; + float slide = (1f - colorReveal(ui)) * m(8f, 8f); + float listY = ui.presetListY + slide; + if (!UnifiedSettingsSkin.inside(mx, my, ui.presetListX, listY, ui.presetListW, Math.max(1f, ui.presetListH))) + return false; + ui.presetsScroll = AnimationUtility.clamp(ui.presetsScroll - (float) amount * m(16f, 12f), 0f, ui.presetsMaxScroll); + return true; + } + + static float getHeight(ColorSetting setting) { + ColorSetting.UiState ui = setting.ui(); + boolean hasAlpha = setting.value().supportsAlpha(); + float dt = AnimationUtility.deltaTime(); + ui.expandAnim = AnimationUtility.approach(ui.expandAnim, ui.editing ? 1f : 0f, dt, ui.editing ? 5.2f : 4.0f); + ui.expandAnim = AnimationUtility.snap(ui.expandAnim, ui.editing ? 1f : 0f, 0.001f); + ui.rgbExpandAnim = AnimationUtility.approach(ui.rgbExpandAnim, ui.showRgbBars ? 1f : 0f, dt, ui.showRgbBars ? 10f : 9f); + ui.rgbExpandAnim = AnimationUtility.snap(ui.rgbExpandAnim, ui.showRgbBars ? 1f : 0f, 0.002f); + ui.presetsExpandAnim = AnimationUtility.approach(ui.presetsExpandAnim, ui.presetsExpanded ? 1f : 0f, dt, ui.presetsExpanded ? 9.5f : 8.5f); + ui.presetsExpandAnim = AnimationUtility.snap(ui.presetsExpandAnim, ui.presetsExpanded ? 1f : 0f, 0.002f); + return colorHeaderH() + colorExpandedHeight(ui, hasAlpha) * colorLayoutAnim(ui); + } + + private static void layoutColor(ColorSetting.UiState ui, float x, float y, float w, boolean hasAlpha) { + int verticalBars = hasAlpha ? 2 : 1; + float sidePad = m(5f, 5f); + float rightInset = m(5f, 4f); + float barW = m(6f, 4f); + float barGap = m(7f, 5f); + ui.squareX = x + sidePad; + ui.squareY = y + colorHeaderH() + m(6f, 5f); + ui.squareH = m(82f, 60f); + ui.squareW = Math.max(m(74f, 44f), w - sidePad * 2f - rightInset - verticalBars * barW - verticalBars * barGap); + ui.hueX = ui.squareX + ui.squareW + barGap; + ui.hueY = ui.squareY; + ui.hueW = barW; + ui.hueH = ui.squareH; + if (hasAlpha) { + ui.alphaX = ui.hueX + barW + barGap; + ui.alphaY = ui.squareY; + ui.alphaW = barW; + ui.alphaH = ui.squareH; + } else { + ui.alphaX = ui.hueX; + ui.alphaY = ui.hueY; + ui.alphaW = 0f; + ui.alphaH = 0f; + } + float rgbReveal = colorRgbReveal(ui); + int rgbBars = hasAlpha ? 4 : 3; + float rgbGapY = m(5f, 5f); + float rgbH = m(9f, 7f); + float valueColumn = m(25f, 18f); + float rgbBlockFull = m(8f, 7f) + rgbBars * rgbH + Math.max(0, rgbBars - 1) * rgbGapY; + float rgbBlockH = rgbBlockFull * rgbReveal; + ui.rX = ui.squareX; + ui.rY = ui.squareY + ui.squareH + m(8f, 7f); + ui.rW = Math.max(1f, ui.squareW - valueColumn); + ui.rH = rgbH; + ui.gX = ui.rX; + ui.gY = ui.rY + rgbH + rgbGapY; + ui.gW = ui.rW; + ui.gH = ui.rH; + ui.bX = ui.rX; + ui.bY = ui.gY + rgbH + rgbGapY; + ui.bW = ui.rW; + ui.bH = ui.rH; + if (hasAlpha) { + ui.aX = ui.rX; + ui.aY = ui.bY + rgbH + rgbGapY; + ui.aW = ui.rW; + ui.aH = ui.rH; + } else { + ui.aX = ui.rX; + ui.aY = ui.bY; + ui.aW = 0f; + ui.aH = 0f; + } + ui.rgbBottom = ui.squareY + ui.squareH + rgbBlockH; + + float actionX = ui.squareX; + float actionW = Math.max(1f, x + w - m(10f, 10f) - actionX); + float gap = m(4f, 4f); + ui.presetX = actionX; + ui.presetY = ui.rgbBottom + m(6f, 4f); + ui.presetW = actionW; + ui.presetH = m(20f, 12f); + + float presetButtonW = m(20f, 14f); + ui.presetToggleW = presetButtonW; + ui.savePresetW = presetButtonW; + ui.deletePresetW = presetButtonW; + ui.presetToggleX = actionX + actionW - presetButtonW * 3f; + ui.savePresetX = ui.presetToggleX + presetButtonW; + ui.deletePresetX = ui.savePresetX + presetButtonW; + ui.savePresetY = ui.presetY; + ui.deletePresetY = ui.presetY; + ui.presetToggleY = ui.presetY; + ui.savePresetH = ui.presetH; + ui.deletePresetH = ui.presetH; + ui.presetToggleH = ui.presetH; + + float listReveal = colorPresetReveal(ui); + float listGap = m(5f, 3f); + float listFullH = m(44f, 31f); + ui.presetListX = actionX; + ui.presetListY = ui.presetY + ui.presetH + listGap; + ui.presetListW = actionW; + ui.presetListH = listFullH * listReveal; + + ui.hexX = actionX; + ui.hexY = ui.presetY + ui.presetH + (listGap + listFullH) * listReveal + m(5f, 3f); + ui.hexW = actionW; + ui.hexH = m(18f, 12f); + float actionY = ui.hexY + ui.hexH + m(5f, 3f); + float bw = Math.max(1f, (actionW - gap) / 2f); + float bh = m(17f, 10.5f); + ui.sliderX = actionX; + ui.sliderY = actionY; + ui.sliderW = bw; + ui.sliderH = bh; + ui.rnbX = ui.sliderX + bw + gap; + ui.rnbY = actionY; + ui.rnbW = bw; + ui.rnbH = bh; + } + + private static void renderColorPreview(float x, float y, float w, float h, int color, float radius) { + ClickGuiRenderer.drawRoundedRect(x, y, w, h, radius, color); + } + + private static void renderColorSquare(ColorSetting.UiState ui, float y) { + int left = hsbToArgb(ui.hue, 0f, 1f, 255); + int right = hsbToArgb(ui.hue, 1f, 1f, 255); + ClickGuiRenderer.drawRoundedRect(ui.squareX, y, ui.squareW, ui.squareH, m(6f, 6f), right); + ClickGuiRenderer.drawGradientRect(ui.squareX, y, ui.squareW, ui.squareH, left, right, right, left); + ClickGuiRenderer.drawGradientRect(ui.squareX, y, ui.squareW, ui.squareH, 0x00FFFFFF, 0x00FFFFFF, 0xFF000000, 0xFF000000); + } + + private static void renderHueBar(ColorSetting.UiState ui, float slide) { + int steps = Math.max(1, Math.round(ui.hueH)); + for (int i = 0; i < steps; i++) { + float h = i / Math.max(1f, ui.hueH - 1f); + ClickGuiRenderer.drawCircle(ui.hueX + ui.hueW * 0.5f, ui.hueY + slide + i, ui.hueW * 0.5f, hsbToArgb(h, 1f, 1f, 255)); + } + } + + private static void renderAlphaBar(ColorSetting.UiState ui, float slide) { + int rgb = hsbToArgb(ui.hue, ui.saturation, ui.brightness, 255); + ClickGuiRenderer.drawGradientRect(ui.alphaX, ui.alphaY + slide, ui.alphaW, ui.alphaH, withAlpha(rgb, 255), withAlpha(rgb, 255), withAlpha(rgb, 0), withAlpha(rgb, 0)); + } + + private static void renderRgbBars(ColorSetting.UiState ui, boolean hasAlpha, float slide) { + float reveal = colorRgbReveal(ui); + if (reveal <= 0.001f) return; + int alpha = hasAlpha ? ui.alpha : 255; + float top = ui.rY + slide - m(2f, 2f); + float bottom = (hasAlpha ? ui.aY + ui.aH : ui.bY + ui.bH) + slide + m(2f, 2f); + boolean clip = ScissorFunction.pushRaw(ui.rX - m(10f, 8f), top, ui.rW + m(40f, 28f), Math.max(1f, (bottom - top) * reveal)); + renderRgbBar("R", ui.rX, ui.rY + slide, ui.rW, ui.rH, (alpha << 24) | (ui.green << 8) | ui.blue, (alpha << 24) | (255 << 16) | (ui.green << 8) | ui.blue, ui.red / 255f, ui.red); + renderRgbBar("G", ui.gX, ui.gY + slide, ui.gW, ui.gH, (alpha << 24) | (ui.red << 16) | ui.blue, (alpha << 24) | (ui.red << 16) | (255 << 8) | ui.blue, ui.green / 255f, ui.green); + renderRgbBar("B", ui.bX, ui.bY + slide, ui.bW, ui.bH, (alpha << 24) | (ui.red << 16) | (ui.green << 8), (alpha << 24) | (ui.red << 16) | (ui.green << 8) | 255, ui.blue / 255f, ui.blue); + if (hasAlpha) { + renderChecker(ui.aX + m(8f, 8f), ui.aY + slide, Math.max(1f, ui.aW - m(16f, 8f)), ui.aH, m(3f, 3f), m(2.5f, 2.5f)); + renderRgbBar("A", ui.aX, ui.aY + slide, ui.aW, ui.aH, (ui.red << 16) | (ui.green << 8) | ui.blue, (255 << 24) | (ui.red << 16) | (ui.green << 8) | ui.blue, ui.alpha / 255f, ui.alpha); + } + if (clip) ScissorFunction.pop(); + } + + private static void renderRgbBar(String label, float x, float y, float w, float h, int c0, int c1, float progress, int value) { + TextRenderer font = UnifiedSettingsSkin.fontLight(); + float size = m(11f, 5.5f); + ClickGuiRenderer.drawText(font, label, x, y - m(0.3f, 0.3f), size, UnifiedSettingsSkin.TEXT_MUTED, false); + float bx = x + m(8f, 8f); + float bw = Math.max(1f, w - m(16f, 8f)); + ClickGuiRenderer.drawGradientRect(bx, y, bw, h, c0, c1, c1, c0); + float px = bx + UnifiedSettingsSkin.clamp01(progress) * bw; + ClickGuiRenderer.drawRoundedRect(px - m(1f, 1f), y - m(1f, 1f), m(2f, 2f), h + m(2f, 2f), m(1f, 1f), UnifiedSettingsSkin.TEXT_PRIMARY); + String valueText = Integer.toString(Math.max(0, Math.min(255, value))); + float valueSize = m(10.5f, 5.2f); + float valueX = x + w + m(3f, 3f); + float valueY = y + (h - UnifiedSettingsSkin.textHeight(font, valueSize)) * 0.5f - m(0.2f, 0.15f); + ClickGuiRenderer.drawText(font, valueText, valueX, valueY, valueSize, UnifiedSettingsSkin.TEXT_MUTED, false); + } + + private static void renderColorPresets(ColorSetting.UiState ui, ColorValue value, float mx, float my, float slide) { + float y = ui.presetY + slide; + float swatch = m(12f, 8f); + float gap = m(4f, 3f); + int labelColor = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 190); + Renderer2D.COLOR.svg("palette", ui.presetX, y + (ui.presetH - swatch) * 0.5f, swatch, swatch, + SvgRenderOptions.overrideColor(labelColor)); + float sx = ui.presetX + swatch + gap; + float available = Math.max(1f, ui.presetToggleX - gap - sx); + java.util.List presets = ColorPresetStore.presets(); + int max = Math.max(1, (int) Math.floor(available / (swatch + gap))); + for (int i = 0; i < Math.min(max, presets.size()); i++) { + float px = sx + i * (swatch + gap); + int color = presets.get(i); + boolean hover = UnifiedSettingsSkin.inside(mx, my, px, y + (ui.presetH - swatch) * 0.5f, swatch, swatch); + renderPresetSwatch(px, y + (ui.presetH - swatch) * 0.5f, swatch, presetDisplayColor(value, color), hover, value.supportsAlpha()); + } + + float dt = AnimationUtility.deltaTime(); + ui.presetsToggleHoverAnim = AnimationUtility.approach(ui.presetsToggleHoverAnim, UnifiedSettingsSkin.inside(mx, my, ui.presetToggleX, y, ui.presetToggleW, ui.presetToggleH) ? 1f : 0f, dt, 14f); + ui.savePresetHoverAnim = AnimationUtility.approach(ui.savePresetHoverAnim, UnifiedSettingsSkin.inside(mx, my, ui.savePresetX, y, ui.savePresetW, ui.savePresetH) ? 1f : 0f, dt, 14f); + ui.deletePresetHoverAnim = AnimationUtility.approach(ui.deletePresetHoverAnim, UnifiedSettingsSkin.inside(mx, my, ui.deletePresetX, y, ui.deletePresetW, ui.deletePresetH) ? 1f : 0f, dt, 14f); + renderColorPresetActionGroup(ui, y); + + float reveal = colorPresetReveal(ui); + if (reveal <= 0.035f) { + ui.presetsMaxScroll = 0f; + ui.presetsContentH = 0f; + ui.presetsViewportH = 0f; + ui.presetListH = 0f; + ui.presetScrollbarX = ui.presetScrollbarY = ui.presetScrollbarW = ui.presetScrollbarH = 0f; + return; + } + + float listY = ui.presetListY + slide; + float listH = ui.presetListH; + if (listH <= m(2f, 1.2f)) { + ui.presetsMaxScroll = 0f; + return; + } + float pad = m(5f, 4f); + UnifiedSettingsSkin.drawSurface(ui.presetListX, listY, ui.presetListW, listH, m(6f, 4f), 0.68f * reveal); + if (UnifiedSettingsSkin.STROKE_GRADIENT_ENABLED) { + UnifiedSettingsSkin.drawStroke(ui.presetListX, listY, ui.presetListW, listH, m(6f, 4f), m(0.65f, 0.42f), 0.32f * reveal); + } else { + ClickGuiRenderer.drawRoundedRectStroke(ui.presetListX, listY, ui.presetListW, listH, m(6f, 4f), m(0.65f, 0.42f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, Math.round(58f * reveal))); + } + + int cols = Math.max(1, (int) Math.floor((ui.presetListW - pad * 2f - m(7f, 5f)) / (swatch + gap))); + int rows = (int) Math.ceil(presets.size() / (double) cols); + float rowH = swatch + gap; + ui.presetsContentH = Math.max(0f, rows * rowH - gap); + ui.presetsViewportH = Math.max(1f, listH - pad * 2f); + ui.presetsMaxScroll = Math.max(0f, ui.presetsContentH - ui.presetsViewportH); + ui.presetsScroll = AnimationUtility.clamp(ui.presetsScroll, 0f, ui.presetsMaxScroll); + + boolean clip = ScissorFunction.pushRaw(ui.presetListX + pad, listY + pad, Math.max(1f, ui.presetListW - pad * 2f - (ui.presetsMaxScroll > 0.5f ? m(5f, 4f) : 0f)), ui.presetsViewportH); + for (int i = 0; i < presets.size(); i++) { + int col = i % cols; + int row = i / cols; + float px = ui.presetListX + pad + col * (swatch + gap); + float py = listY + pad + row * rowH - ui.presetsScroll; + if (py + swatch < listY + pad - 1f || py > listY + pad + ui.presetsViewportH + 1f) continue; + boolean hover = UnifiedSettingsSkin.inside(mx, my, px, py, swatch, swatch); + renderPresetSwatch(px, py, swatch, presetDisplayColor(value, presets.get(i)), hover, value.supportsAlpha()); + } + if (clip) ScissorFunction.pop(); + + if (ui.presetsMaxScroll > 0.5f) { + ui.presetScrollbarW = m(2.5f, 1.7f); + ui.presetScrollbarX = ui.presetListX + ui.presetListW - pad - ui.presetScrollbarW; + ui.presetScrollbarY = listY + pad; + ui.presetScrollbarH = ui.presetsViewportH; + ui.presetScrollbarHandleH = Math.max(m(12f, 8f), ui.presetScrollbarH * (ui.presetScrollbarH / (ui.presetScrollbarH + ui.presetsMaxScroll))); + ui.presetScrollbarHandleY = ui.presetScrollbarY + (ui.presetScrollbarH - ui.presetScrollbarHandleH) * (ui.presetsScroll / Math.max(1f, ui.presetsMaxScroll)); + boolean barHover = UnifiedSettingsSkin.inside(mx, my, ui.presetScrollbarX - m(3f, 2f), ui.presetScrollbarY, ui.presetScrollbarW + m(6f, 4f), ui.presetScrollbarH) || ui.presetsScrollbarDragging; + ui.presetsScrollbarHoverAnim = AnimationUtility.approach(ui.presetsScrollbarHoverAnim, barHover ? 1f : 0f, dt, 14f); + ClickGuiRenderer.drawRoundedRect(ui.presetScrollbarX, ui.presetScrollbarY, ui.presetScrollbarW, ui.presetScrollbarH, ui.presetScrollbarW * 0.5f, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 42)); + ClickGuiRenderer.drawRoundedRect(ui.presetScrollbarX, ui.presetScrollbarHandleY, ui.presetScrollbarW, ui.presetScrollbarHandleH, ui.presetScrollbarW * 0.5f, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 95 + Math.round(65f * ui.presetsScrollbarHoverAnim))); + if (ui.presetsScrollbarDragging) { + updatePresetScrollbarDrag(ui, my); + } + } else { + ui.presetScrollbarX = ui.presetScrollbarY = ui.presetScrollbarW = ui.presetScrollbarH = 0f; + } + } + + private static void renderPresetSwatch(float x, float y, float size, int color, boolean hover, boolean alphaContext) { + int stroke = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, hover ? 130 : 78); + if (alphaContext || ((color >>> 24) & 0xFF) < 255) { + renderChecker(x, y, size, size, m(3f, 2f), m(3f, 2f)); + } + ClickGuiRenderer.drawRoundedRect(x, y, size, size, m(3f, 2f), color); + ClickGuiRenderer.drawRoundedRectStroke(x, y, size, size, m(3f, 2f), m(0.7f, 0.45f), stroke); + } + + private static int presetDisplayColor(ColorValue value, int argb) { + return value.supportsAlpha() ? argb : (0xFF000000 | (argb & 0x00FFFFFF)); + } + + private static void renderColorPresetActionGroup(ColorSetting.UiState ui, float y) { + float x = ui.presetToggleX; + float w = ui.presetToggleW + ui.savePresetW + ui.deletePresetW; + float h = ui.presetToggleH; + float hover = Math.max(ui.presetsToggleHoverAnim, Math.max(ui.savePresetHoverAnim, ui.deletePresetHoverAnim)); + float active = ui.presetsExpanded ? 1f : 0f; + if (ui.presetsExpanded) { + UnifiedSettingsSkin.drawAccent(x, y, w, h, m(5f, 4f), 0.32f + hover * 0.16f); + } else { + UnifiedSettingsSkin.drawSurface(x, y, w, h, m(5f, 4f), 0.58f + hover * 0.24f); + } + int stroke = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 62), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 150), active); + stroke = UnifiedSettingsSkin.mix(stroke, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 120), hover * 0.35f); + if (UnifiedSettingsSkin.STROKE_GRADIENT_ENABLED || ui.presetsExpanded) { + ClickGuiRenderer.drawRoundedRectStrokeGradient(x, y, w, h, m(5f, 4f), m(0.7f, 0.45f), UnifiedSettingsSkin.accentGradientStart(0.58f + active * 0.20f), UnifiedSettingsSkin.accentGradientEnd(0.58f + active * 0.20f), UnifiedSettingsSkin.ACCENT_GRADIENT_ANGLE); + } else { + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, m(5f, 4f), m(0.7f, 0.45f), stroke); + } + + float sepW = m(0.7f, 0.45f); + int sep = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 55 + Math.round(35f * hover)); + ClickGuiRenderer.drawRect(ui.savePresetX, y + m(3f, 2f), sepW, h - m(6f, 4f), sep); + ClickGuiRenderer.drawRect(ui.deletePresetX, y + m(3f, 2f), sepW, h - m(6f, 4f), sep); + + renderPresetActionIcon("folder-closed", ui.presetToggleX, y, ui.presetToggleW, h, ui.presetsToggleHoverAnim, ui.presetsExpanded); + renderPresetActionIcon("heart-plus", ui.savePresetX, y, ui.savePresetW, h, ui.savePresetHoverAnim, false); + renderPresetActionIcon("minus", ui.deletePresetX, y, ui.deletePresetW, h, ui.deletePresetHoverAnim, false); + } + + private static void renderPresetActionIcon(String icon, float x, float y, float w, float h, float hoverAnim, boolean active) { + float size = Math.min(m(11f, 7f), Math.max(m(8f, 5f), Math.min(w, h) - m(5f, 3f))); + int color = active + ? UnifiedSettingsSkin.ACCENT + : UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, 0.55f + hoverAnim * 0.35f); + Renderer2D.COLOR.svg(icon, x + (w - size) * 0.5f, y + (h - size) * 0.5f, size, size, + SvgRenderOptions.overrideColor(color)); + } + + private static void renderHexInput(ColorSetting.UiState ui, ColorValue value, float mx, float my, float slide) { + float y = ui.hexY + slide; + boolean hover = UnifiedSettingsSkin.inside(mx, my, ui.hexX, y, ui.hexW, ui.hexH); + float dt = AnimationUtility.deltaTime(); + ui.hexAnim = AnimationUtility.approach(ui.hexAnim, ui.hexFocused ? 1f : hover ? 0.55f : 0f, dt, 13f); + ui.hexErrorAnim = AnimationUtility.approach(ui.hexErrorAnim, 0f, dt, 7.5f); + if (ui.hexFocused) ui.hexCursorBlink += dt * 2.2f; + else ui.hexCursorBlink = 0f; + int bg = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, ui.hexAnim), 58 + Math.round(35f * ui.hexAnim)); + int stroke = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 70), 0xFFFF5F66, ui.hexErrorAnim); + ClickGuiRenderer.drawRoundedRect(ui.hexX, y, ui.hexW, ui.hexH, m(5f, 3f), bg); + ClickGuiRenderer.drawRoundedRectStroke(ui.hexX, y, ui.hexW, ui.hexH, m(5f, 3f), m(0.7f, 0.45f), stroke); + int icon = UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 185); + Renderer2D.COLOR.svg("brush", ui.hexX + m(5f, 3f), y + (ui.hexH - m(9f, 6f)) * 0.5f, m(9f, 6f), m(9f, 6f), + SvgRenderOptions.overrideColor(icon)); + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(11.5f, 5.4f); + String text = ui.hexFocused ? ui.hexBuffer : displayHex(value, value.supportsAlpha()); + String label = "HEX"; + float tx = ui.hexX + m(18f, 12f); + ClickGuiRenderer.drawText(font, label, tx, y + (ui.hexH - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f, size, UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_MUTED, 175), false); + float labelW = UnifiedSettingsSkin.textWidth(font, label, size) + m(5f, 4f); + int textColor = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_PRIMARY, 0xFFFF7777, ui.hexErrorAnim); + float textX = tx + labelW; + float textY = y + (ui.hexH - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f; + float textClipW = Math.max(1f, ui.hexW - (textX - ui.hexX) - m(25f, 17f)); + boolean hexClip = ScissorFunction.pushRaw(textX, y, textClipW, ui.hexH); + String prefix = "#"; + String draw = prefix + text; + if (ui.hexFocused) { + renderInlineSelection(font, text, hexSelStart(ui), hexSelEnd(ui), textX + UnifiedSettingsSkin.textWidth(font, prefix, size), textY, size, ui.hexH, y); + ClickGuiRenderer.drawText(font, draw, textX, textY, size, textColor, false); + if (((int) (ui.hexCursorBlink * 2f) & 1) == 0) { + int cursor = Math.max(0, Math.min(ui.hexCursor, ui.hexBuffer.length())); + float cx = textX + UnifiedSettingsSkin.textWidth(font, prefix + ui.hexBuffer.substring(0, cursor), size) + m(0.5f, 0.3f); + ClickGuiRenderer.drawRect(cx, y + m(3f, 2f), m(0.65f, 0.4f), ui.hexH - m(6f, 4f), UnifiedSettingsSkin.withAlpha(textColor, 190)); + } + } else { + String display = "#" + UnifiedSettingsSkin.fit(font, text, size, textClipW); + ClickGuiRenderer.drawText(font, display, textX, textY, size, textColor, false); + } + if (hexClip) ScissorFunction.pop(); + float preview = ui.hexH - m(5f, 3f); + float px = ui.hexX + ui.hexW - preview - m(3f, 2f); + renderChecker(px, y + (ui.hexH - preview) * 0.5f, preview, preview, m(3f, 2f), m(3f, 2f)); + ClickGuiRenderer.drawRoundedRect(px, y + (ui.hexH - preview) * 0.5f, preview, preview, m(3f, 2f), value.getArgb()); + } + + private static void renderColorMarkers(ColorSetting.UiState ui, boolean hasAlpha, float slide) { + float sbX = ui.squareX + ui.saturation * ui.squareW; + float sbY = ui.squareY + slide + (1f - ui.brightness) * ui.squareH; + ClickGuiRenderer.drawCircle(sbX, sbY, m(4f, 4f), 0xFF000000); + ClickGuiRenderer.drawCircle(sbX, sbY, m(3f, 3f), 0xFFFFFFFF); + float hueY = ui.hueY + slide + ui.hue * Math.max(1f, ui.hueH - 1f); + ClickGuiRenderer.drawCircle(ui.hueX + ui.hueW * 0.5f, hueY, m(4f, 4f), 0xFF000000); + ClickGuiRenderer.drawCircle(ui.hueX + ui.hueW * 0.5f, hueY, m(3f, 3f), 0xFFFFFFFF); + if (hasAlpha) { + float alphaY = ui.alphaY + slide + (1f - ui.alpha / 255f) * Math.max(1f, ui.alphaH - 1f); + ClickGuiRenderer.drawCircle(ui.alphaX + ui.alphaW * 0.5f, alphaY, m(4f, 4f), 0xFF000000); + ClickGuiRenderer.drawCircle(ui.alphaX + ui.alphaW * 0.5f, alphaY, m(3f, 3f), 0xFFFFFFFF); + } + } + + private static void renderColorActions(ColorSetting.UiState ui, ColorValue value, float mx, float my, float slide) { + float dt = AnimationUtility.deltaTime(); + boolean rgbHover = UnifiedSettingsSkin.inside(mx, my, ui.sliderX, ui.sliderY + slide, ui.sliderW, ui.sliderH); + boolean rnbHover = UnifiedSettingsSkin.inside(mx, my, ui.rnbX, ui.rnbY + slide, ui.rnbW, ui.rnbH); + ui.sliderHoverAnim = AnimationUtility.approach(ui.sliderHoverAnim, rgbHover ? 1f : 0f, dt, 14f); + ui.rnbHoverAnim = AnimationUtility.approach(ui.rnbHoverAnim, rnbHover ? 1f : 0f, dt, 14f); + ui.rnbOffAnim = AnimationUtility.approach(ui.rnbOffAnim, value.isRainbow() ? 0f : 1f, dt, 8f); + renderChipButton(ui.sliderX, ui.sliderY + slide, ui.sliderW, ui.sliderH, "RGB", ui.sliderHoverAnim, ui.showRgbBars); + renderRainbowChipButton(ui.rnbX, ui.rnbY + slide, ui.rnbW, ui.rnbH, ui.rnbHoverAnim, value.isRainbow()); + } + + private static boolean handleColorPresetClick(ColorSetting setting, ColorValue value, ColorSetting.UiState ui, double mx, double my, int button) { + if (button != 0 && button != 1) return false; + float slide = (1f - colorReveal(ui)) * m(8f, 8f); + float rowY = ui.presetY + slide; + float swatch = m(12f, 8f); + float gap = m(4f, 3f); + float sx = ui.presetX + swatch + gap; + float available = Math.max(1f, ui.presetToggleX - gap - sx); + java.util.List presets = ColorPresetStore.presets(); + int max = Math.max(1, (int) Math.floor(available / (swatch + gap))); + for (int i = 0; i < Math.min(max, presets.size()); i++) { + float px = sx + i * (swatch + gap); + if (!UnifiedSettingsSkin.inside(mx, my, px, rowY + (ui.presetH - swatch) * 0.5f, swatch, swatch)) continue; + int color = presets.get(i); + if (button == 1) ColorPresetStore.removeExact(color); + else applyColor(value, color); + syncColorFromValue(ui, value); + if (setting.getParent() != null) setting.getParent().saveConfig(); + return true; + } + if (button == 0 && UnifiedSettingsSkin.inside(mx, my, ui.presetToggleX, rowY, ui.presetToggleW, ui.presetToggleH)) { + ui.presetsExpanded = !ui.presetsExpanded; + return true; + } + if (button == 0 && UnifiedSettingsSkin.inside(mx, my, ui.savePresetX, rowY, ui.savePresetW, ui.savePresetH)) { + ColorPresetStore.add(value.getArgb()); + return true; + } + if (button == 0 && UnifiedSettingsSkin.inside(mx, my, ui.deletePresetX, rowY, ui.deletePresetW, ui.deletePresetH)) { + ColorPresetStore.removeClosest(value.getArgb()); + return true; + } + + if (ui.presetsExpanded && ui.presetsExpandAnim > 0.05f) { + float listY = ui.presetListY + slide; + if (button == 0 && ui.presetsMaxScroll > 0.5f && UnifiedSettingsSkin.inside(mx, my, ui.presetScrollbarX - m(3f, 2f), ui.presetScrollbarY, ui.presetScrollbarW + m(6f, 4f), ui.presetScrollbarH)) { + ui.presetsScrollbarDragging = true; + ui.presetsScrollbarDragOffset = (float) my - ui.presetScrollbarHandleY; + updatePresetScrollbarDrag(ui, (float) my); + return true; + } + if (UnifiedSettingsSkin.inside(mx, my, ui.presetListX, listY, ui.presetListW, Math.max(1f, ui.presetListH))) { + float pad = m(5f, 4f); + float innerX = ui.presetListX + pad; + float innerY = listY + pad; + int cols = Math.max(1, (int) Math.floor((ui.presetListW - pad * 2f - m(7f, 5f)) / (swatch + gap))); + float rowH = swatch + gap; + float localX = (float) mx - innerX; + float localY = (float) my - innerY + ui.presetsScroll; + int col = (int) Math.floor(localX / (swatch + gap)); + int row = (int) Math.floor(localY / rowH); + if (col >= 0 && col < cols && row >= 0) { + float cellX = col * (swatch + gap); + float cellY = row * rowH; + if (localX >= cellX && localX <= cellX + swatch && localY >= cellY && localY <= cellY + swatch) { + int index = row * cols + col; + if (index >= 0 && index < presets.size()) { + int color = presets.get(index); + if (button == 1) ColorPresetStore.removeExact(color); + else applyColor(value, color); + syncColorFromValue(ui, value); + if (setting.getParent() != null) setting.getParent().saveConfig(); + return true; + } + } + } + } + } + return false; + } + + private static void beginHexEdit(ColorSetting.UiState ui, ColorValue value) { + ui.hexFocused = true; + ui.hexBuffer = displayHex(value, value.supportsAlpha()); + ui.hexCursor = ui.hexBuffer.length(); + ui.hexSelection.clear(); + ui.hexErrorAnim = 0f; + } + + private static void cancelHexEdit(ColorSetting.UiState ui, ColorValue value) { + ui.hexFocused = false; + ui.hexBuffer = displayHex(value, value.supportsAlpha()); + ui.hexCursor = ui.hexBuffer.length(); + ui.hexSelection.clear(); + ui.hexErrorAnim = 0f; + } + + private static boolean commitHexEdit(ColorSetting setting, ColorValue value, ColorSetting.UiState ui, boolean save) { + Integer parsed = parseHexInput(ui.hexBuffer, value.supportsAlpha()); + if (parsed == null) { + ui.hexErrorAnim = 1f; + return false; + } + applyColor(value, parsed); + syncColorFromValue(ui, value); + ui.hexFocused = false; + ui.hexSelection.clear(); + if (save && setting.getParent() != null) setting.getParent().saveConfig(); + return true; + } + + private static String displayHex(ColorValue value, boolean hasAlpha) { + int argb = value.isRainbow() ? parseFallbackHex(value.rainbowFallbackHex(), value.getArgb()) : value.getArgb(); + if (hasAlpha) + return String.format("%02X%02X%02X%02X", (argb >>> 24) & 0xFF, (argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF); + return String.format("%02X%02X%02X", (argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF); + } + + private static int parseFallbackHex(String hex, int fallback) { + Integer parsed = parseHexInput(hex, true); + return parsed == null ? fallback : parsed; + } + + private static Integer parseHexInput(String raw, boolean hasAlpha) { + if (raw == null) return null; + StringBuilder clean = new StringBuilder(); + String t = raw.trim(); + if (t.startsWith("#")) t = t.substring(1); + if (t.startsWith("0x") || t.startsWith("0X")) t = t.substring(2); + for (int i = 0; i < t.length(); i++) { + char c = t.charAt(i); + if (isHexChar(c)) clean.append(Character.toUpperCase(c)); + } + String s = clean.toString(); + try { + if (s.length() == 3) { + int r = Integer.parseInt(s.substring(0, 1) + s.substring(0, 1), 16); + int g = Integer.parseInt(s.substring(1, 2) + s.substring(1, 2), 16); + int b = Integer.parseInt(s.substring(2, 3) + s.substring(2, 3), 16); + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + if (s.length() == 6) { + return (int) (0xFF000000L | Long.parseUnsignedLong(s, 16)); + } + if (hasAlpha && s.length() == 8) { + return (int) Long.parseUnsignedLong(s, 16); + } + if (!hasAlpha && s.length() == 8) { + return (int) (0xFF000000L | (Long.parseUnsignedLong(s.substring(2), 16) & 0xFFFFFFL)); + } + } catch (Throwable ignored) { + } + return null; + } + + private static boolean isHexChar(char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + } + + private static void insertHexText(ColorSetting.UiState ui, String text) { + if (text == null || text.isEmpty()) return; + deleteHexSelection(ui); + StringBuilder insert = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (isHexChar(c)) insert.append(Character.toUpperCase(c)); + } + if (insert.length() == 0) { + ui.hexErrorAnim = 1f; + return; + } + int maxLen = 8; + int allowed = Math.max(0, maxLen - ui.hexBuffer.length()); + if (insert.length() > allowed) { + insert.setLength(allowed); + ui.hexErrorAnim = 1f; + } + if (insert.length() == 0) return; + ui.hexBuffer = ui.hexBuffer.substring(0, ui.hexCursor) + insert + ui.hexBuffer.substring(ui.hexCursor); + ui.hexCursor += insert.length(); + } + + private static void selectAllHex(ColorSetting.UiState ui) { + ui.hexSelection.begin(0, 0); + ui.hexSelection.updateCaret(0, ui.hexBuffer == null ? 0 : ui.hexBuffer.length()); + ui.hexCursor = ui.hexBuffer == null ? 0 : ui.hexBuffer.length(); + } + + private static String selectedOrAllHex(ColorSetting.UiState ui) { + if (hasHexSelection(ui)) return ui.hexBuffer.substring(hexSelStart(ui), hexSelEnd(ui)); + return ui.hexBuffer == null ? "" : ui.hexBuffer; + } + + private static boolean hasHexSelection(ColorSetting.UiState ui) { + return ui.hexSelection.hasRange(); + } + + private static int hexSelStart(ColorSetting.UiState ui) { + return hasHexSelection(ui) ? Math.max(0, Math.min(ui.hexSelection.start(), ui.hexBuffer.length())) : -1; + } + + private static int hexSelEnd(ColorSetting.UiState ui) { + return hasHexSelection(ui) ? Math.max(0, Math.min(ui.hexSelection.end(), ui.hexBuffer.length())) : -1; + } + + private static boolean deleteHexSelection(ColorSetting.UiState ui) { + if (!hasHexSelection(ui)) return false; + int start = hexSelStart(ui); + int end = hexSelEnd(ui); + ui.hexBuffer = ui.hexBuffer.substring(0, start) + ui.hexBuffer.substring(end); + ui.hexCursor = start; + ui.hexSelection.clear(); + return true; + } + + private static void moveHexCursor(ColorSetting.UiState ui, int dir, boolean shift) { + setHexCursor(ui, Math.max(0, Math.min(ui.hexBuffer.length(), ui.hexCursor + dir)), shift); + } + + private static void setHexCursor(ColorSetting.UiState ui, int pos, boolean shift) { + int next = Math.max(0, Math.min(ui.hexBuffer == null ? 0 : ui.hexBuffer.length(), pos)); + if (shift) { + if (!ui.hexSelection.hasRange()) ui.hexSelection.begin(0, ui.hexCursor); + ui.hexSelection.updateCaret(0, next); + } else { + ui.hexSelection.clear(); + } + ui.hexCursor = next; + ui.hexCursorBlink = 0f; + } + + private static void setHexCursorFromMouse(ColorSetting.UiState ui, float mx, boolean shift) { + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + float size = m(11.5f, 5.4f); + String label = "HEX"; + float tx = ui.hexX + m(18f, 12f); + float labelW = UnifiedSettingsSkin.textWidth(font, label, size) + m(5f, 4f); + float textX = tx + labelW + UnifiedSettingsSkin.textWidth(font, "#", size); + int cursor = caretFromText(font, ui.hexBuffer == null ? "" : ui.hexBuffer, size, textX, mx); + setHexCursor(ui, cursor, shift); + } + + private static void updateColorDrag(ColorSetting.UiState ui, ColorValue value, float mx, float my, boolean hasAlpha) { + if (value.isRainbow()) return; + if (ui.sbFocused) { + ui.saturation = UnifiedSettingsSkin.clamp01((mx - ui.squareX) / Math.max(1f, ui.squareW)); + ui.brightness = UnifiedSettingsSkin.clamp01(1f - ((my - ui.squareY) / Math.max(1f, ui.squareH))); + setColorFromHsb(ui, value); + } + if (ui.hFocused) { + ui.hue = UnifiedSettingsSkin.clamp01((my - ui.hueY) / Math.max(1f, ui.hueH)); + setColorFromHsb(ui, value); + } + if (hasAlpha && ui.aFocused) { + ui.alpha = Math.round(UnifiedSettingsSkin.clamp01(1f - ((my - ui.alphaY) / Math.max(1f, ui.alphaH))) * 255f); + setColorFromHsb(ui, value); + } + if (ui.rFocused) { + ui.red = Math.round(UnifiedSettingsSkin.clamp01((mx - ui.rX) / Math.max(1f, ui.rW)) * 255f); + setColorFromRgb(ui, value); + } + if (ui.gFocused) { + ui.green = Math.round(UnifiedSettingsSkin.clamp01((mx - ui.gX) / Math.max(1f, ui.gW)) * 255f); + setColorFromRgb(ui, value); + } + if (ui.bFocused) { + ui.blue = Math.round(UnifiedSettingsSkin.clamp01((mx - ui.bX) / Math.max(1f, ui.bW)) * 255f); + setColorFromRgb(ui, value); + } + if (hasAlpha && ui.aRgbFocused) { + ui.alpha = Math.round(UnifiedSettingsSkin.clamp01((mx - ui.aX) / Math.max(1f, ui.aW)) * 255f); + setColorFromRgb(ui, value); + } + } + + private static float colorHeaderH() { + return m(48f, 18f); + } + + private static float colorExpandedHeight(ColorSetting.UiState ui, boolean hasAlpha) { + int bars = hasAlpha ? 4 : 3; + float rgbBlock = (m(8f, 7f) + bars * m(9f, 7f) + (bars - 1) * m(5f, 5f)) * colorRgbReveal(ui); + float presets = m(20f, 12f); + float presetsExpanded = (m(5f, 3f) + m(44f, 31f)) * colorPresetReveal(ui); + float hex = m(18f, 12f); + float actions = m(17f, 10.5f); + return Math.max(0f, colorHeaderH() + m(6f, 5f) + m(82f, 60f) + rgbBlock + m(6f, 4f) + presets + presetsExpanded + m(5f, 3f) + hex + m(5f, 3f) + actions + m(14f, 8f) - colorHeaderH()); + } + + private static float colorRgbReveal(ColorSetting.UiState ui) { + return AnimationUtility.easeInOutCubic(UnifiedSettingsSkin.clamp01(ui.rgbExpandAnim)); + } + + private static float colorPresetReveal(ColorSetting.UiState ui) { + return AnimationUtility.easeInOutCubic(UnifiedSettingsSkin.clamp01(ui.presetsExpandAnim)); + } + + private static void updatePresetScrollbarDrag(ColorSetting.UiState ui, float my) { + if (ui.presetsMaxScroll <= 0.5f) return; + float track = Math.max(1f, ui.presetScrollbarH - ui.presetScrollbarHandleH); + float handleY = AnimationUtility.clamp(my - ui.presetsScrollbarDragOffset, ui.presetScrollbarY, ui.presetScrollbarY + track); + float t = (handleY - ui.presetScrollbarY) / track; + ui.presetsScroll = AnimationUtility.clamp(t * ui.presetsMaxScroll, 0f, ui.presetsMaxScroll); + } + + private static float colorLayoutAnim(ColorSetting.UiState ui) { + float t = UnifiedSettingsSkin.clamp01(ui.expandAnim); + return ui.editing ? AnimationUtility.clamp(AnimationUtility.easeOutBack(t, 0.42f), 0f, 1.02f) : 1f - AnimationUtility.easeInCubic(1f - t); + } + + private static float colorReveal(ColorSetting.UiState ui) { + float t = UnifiedSettingsSkin.clamp01(ui.expandAnim); + if (ui.editing) return AnimationUtility.easeInOutCubic(UnifiedSettingsSkin.clamp01((t - 0.08f) / 0.92f)); + return 1f - AnimationUtility.easeInCubic(1f - t); + } + + private static void syncColorFromValue(ColorSetting.UiState ui, ColorValue value) { + int argb = value.getArgb(); + ui.lastArgb = argb; + ui.red = (argb >>> 16) & 0xFF; + ui.green = (argb >>> 8) & 0xFF; + ui.blue = argb & 0xFF; + ui.alpha = (argb >>> 24) & 0xFF; + float[] hsb = Color.RGBtoHSB(ui.red, ui.green, ui.blue, null); + if (hsb[1] > 0.0001f && hsb[2] > 0.0001f) ui.hue = hsb[0]; + ui.saturation = hsb[1]; + ui.brightness = hsb[2]; + } + + private static void setColorFromHsb(ColorSetting.UiState ui, ColorValue value) { + int rgb = Color.HSBtoRGB(ui.hue, ui.saturation, ui.brightness) & 0x00FFFFFF; + ui.red = (rgb >>> 16) & 0xFF; + ui.green = (rgb >>> 8) & 0xFF; + ui.blue = rgb & 0xFF; + applyColor(value, ((value.supportsAlpha() ? ui.alpha : 255) << 24) | rgb); + } + + private static void setColorFromRgb(ColorSetting.UiState ui, ColorValue value) { + float[] hsb = Color.RGBtoHSB(ui.red, ui.green, ui.blue, null); + if (hsb[1] > 0.0001f && hsb[2] > 0.0001f) ui.hue = hsb[0]; + ui.saturation = hsb[1]; + ui.brightness = hsb[2]; + applyColor(value, ((value.supportsAlpha() ? ui.alpha : 255) << 24) | (ui.red << 16) | (ui.green << 8) | ui.blue); + } + + private static void applyColor(ColorValue value, int argb) { + if (value.supportsAlpha()) + value.set(String.format("#%02X%02X%02X%02X", (argb >>> 24) & 0xFF, (argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF)); + else value.set(String.format("#%02X%02X%02X", (argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF)); + } + + private static void pasteColor(ColorSetting setting, ColorValue value) { + applyColor(value, ClickGuiRenderer.getColorClipboard()); + syncColorFromValue(setting.ui(), value); + if (setting.getParent() != null) setting.getParent().saveConfig(); + } + + private static boolean colorDragActive(ColorSetting.UiState ui) { + return ui.sbFocused || ui.hFocused || ui.aFocused || ui.rFocused || ui.gFocused || ui.bFocused || ui.aRgbFocused; + } + + private static void clearColorFocus(ColorSetting.UiState ui) { + ui.sbFocused = false; + ui.hFocused = false; + ui.aFocused = false; + ui.rFocused = false; + ui.gFocused = false; + ui.bFocused = false; + ui.aRgbFocused = false; + } + + private static int hsbToArgb(float h, float s, float b, int a) { + return (Math.max(0, Math.min(255, a)) << 24) | (Color.HSBtoRGB(h, s, b) & 0x00FFFFFF); + } + + private static int withAlpha(int argb, int a) { + return (argb & 0x00FFFFFF) | ((Math.max(0, Math.min(255, a)) & 0xFF) << 24); + } + + private static int caretFromText(TextRenderer font, String text, float size, float textX, float mx) { + if (text == null || text.isEmpty()) return 0; + float rel = mx - textX; + if (rel <= 0f) return 0; + for (int i = 1; i <= text.length(); i++) { + float prev = UnifiedSettingsSkin.textWidth(font, text.substring(0, i - 1), size); + float cur = UnifiedSettingsSkin.textWidth(font, text.substring(0, i), size); + if (rel < (prev + cur) * 0.5f) return i - 1; + } + return text.length(); + } + + // ------------------------------------------------------------ + // Common helpers + // ------------------------------------------------------------ + + private static void renderInlineSelection(TextRenderer font, String text, int start, int end, float textX, float textY, float size, float fieldH, float fieldY) { + if (text == null || start < 0 || end < 0 || start == end) return; + int a = Math.max(0, Math.min(start, text.length())); + int b = Math.max(0, Math.min(end, text.length())); + if (a == b) return; + if (a > b) { + int t = a; + a = b; + b = t; + } + float x0 = textX + UnifiedSettingsSkin.textWidth(font, text.substring(0, a), size); + float x1 = textX + UnifiedSettingsSkin.textWidth(font, text.substring(0, b), size); + float h = Math.max(m(8f, 5f), UnifiedSettingsSkin.textHeight(font, size) + m(3f, 2f)); + float y = fieldY > 0f ? fieldY + (fieldH - h) * 0.5f : textY - m(1.5f, 0.7f); + ClickGuiRenderer.drawRoundedRect(x0, y, Math.max(m(1f, 0.7f), x1 - x0), h, m(2f, 1.5f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 92)); + } + + private static void renderRainbowChipButton(float x, float y, float w, float h, float hoverAnim, boolean active) { + float t = (System.nanoTime() % 3_000_000_000L) / 3_000_000_000f; + int c0 = hsbToArgb((t + 0.00f) % 1f, 0.80f, 1.0f, active ? 205 : 92 + Math.round(hoverAnim * 34f)); + int c1 = hsbToArgb((t + 0.18f) % 1f, 0.80f, 1.0f, active ? 215 : 96 + Math.round(hoverAnim * 38f)); + int c2 = hsbToArgb((t + 0.36f) % 1f, 0.80f, 1.0f, active ? 215 : 96 + Math.round(hoverAnim * 38f)); + int c3 = hsbToArgb((t + 0.54f) % 1f, 0.80f, 1.0f, active ? 205 : 92 + Math.round(hoverAnim * 34f)); + if (active) { + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, m(4f, 4f), c0, c2, 25f + t * 360f); + ClickGuiRenderer.drawRoundedRect(x, y, w, h, m(4f, 4f), UnifiedSettingsSkin.withAlpha(0xFF000000, 18 - Math.round(hoverAnim * 6f))); + ClickGuiRenderer.drawRoundedRectStrokeGradient(x, y, w, h, m(4f, 4f), m(0.8f, 0.45f), c0, c2, 25f + t * 360f); + } else { + int bg = UnifiedSettingsSkin.mix(UnifiedSettingsSkin.SURFACE_SOFT, UnifiedSettingsSkin.SURFACE_HOVER, hoverAnim * 0.75f); + ClickGuiRenderer.drawRoundedRect(x, y, w, h, m(4f, 4f), UnifiedSettingsSkin.withAlpha(bg, 70 + Math.round(hoverAnim * 42f))); + if (hoverAnim > 0.02f) { + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, m(4f, 4f), UnifiedSettingsSkin.withAlpha(c0, Math.round(52f * hoverAnim)), UnifiedSettingsSkin.withAlpha(c2, Math.round(52f * hoverAnim)), 25f + t * 360f); + ClickGuiRenderer.drawRoundedRectStrokeGradient(x, y, w, h, m(4f, 4f), m(0.7f, 0.45f), UnifiedSettingsSkin.withAlpha(c0, 80 + Math.round(96f * hoverAnim)), UnifiedSettingsSkin.withAlpha(c2, 80 + Math.round(96f * hoverAnim)), 25f + t * 360f); + } else { + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, m(4f, 4f), m(0.7f, 0.45f), UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 72)); + } + } + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + String label = "RNB"; + float size = Math.min(m(13f, 6f), Math.max(m(10f, 4.8f), (w - m(4f, 4f)) / Math.max(1f, label.length()) * 1.45f)); + float tw = UnifiedSettingsSkin.textWidth(font, label, size); + float ty = y + (h - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f - m(0.2f, 0.2f); + int labelColor = active ? 0xFFFFFFFF : UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, 0xFFFFFFFF, hoverAnim * 0.62f); + ClickGuiRenderer.drawText(font, label, x + Math.max(m(2f, 2f), (w - tw) * 0.5f), ty, size, labelColor, false); + } + + private static void renderChipButton(float x, float y, float w, float h, String label, float hoverAnim, boolean active) { + TextRenderer font = UnifiedSettingsSkin.fontMedium(); + int bg = active ? UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 70) : UnifiedSettingsSkin.SURFACE_SOFT; + bg = UnifiedSettingsSkin.mix(bg, UnifiedSettingsSkin.SURFACE_HOVER, hoverAnim * 0.72f); + ClickGuiRenderer.drawRoundedRect(x, y, w, h, m(4f, 4f), bg); + int stroke = active + ? UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.ACCENT, 135 + Math.round(hoverAnim * 55f)) + : UnifiedSettingsSkin.withAlpha(UnifiedSettingsSkin.TEXT_FAINT, 58 + Math.round(hoverAnim * 65f)); + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, m(4f, 4f), m(0.7f, 0.45f), stroke); + float size = Math.min(m(13f, 6f), Math.max(m(10f, 4.8f), (w - m(4f, 4f)) / Math.max(1f, label.length()) * 1.45f)); + float tw = UnifiedSettingsSkin.textWidth(font, label, size); + float ty = y + (h - UnifiedSettingsSkin.textHeight(font, size)) * 0.5f - m(0.2f, 0.2f); + int color = active ? UnifiedSettingsSkin.ACCENT : UnifiedSettingsSkin.mix(UnifiedSettingsSkin.TEXT_MUTED, UnifiedSettingsSkin.TEXT_PRIMARY, 0.68f + hoverAnim * 0.32f); + ClickGuiRenderer.drawText(font, label, x + Math.max(m(2f, 2f), (w - tw) * 0.5f), ty, size, color, false); + } + + private static void renderChecker(float x, float y, float w, float h, float cell, float radius) { + int light = 0xFFE7E7E7; + int dark = 0xFF777777; + ClickGuiRenderer.drawRoundedRect(x, y, w, h, radius, light); + float safeCell = Math.max(1f, cell); + int cols = Math.max(1, (int) Math.ceil(w / safeCell)); + int rows = Math.max(1, (int) Math.ceil(h / safeCell)); + boolean clipped = ScissorFunction.pushRaw(x, y, w, h); + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + if (((row + col) & 1) == 0) continue; + ClickGuiRenderer.drawRect(x + col * safeCell, y + row * safeCell, safeCell, safeCell, dark); + } + } + if (clipped) ScissorFunction.pop(); + } + + private static float calculateTextBoxHeight(TextRenderer font, String text, float size, float maxW) { + String safe = text == null || text.isEmpty() ? " " : text; + int lines = Math.max(1, ClickGuiRenderer.wrapText(font, safe, size, maxW, 6).size()); + return m(5f, 5f) + lines * UnifiedSettingsSkin.textHeight(font, size) + Math.max(0, lines - 1) * m(1f, 1f); + } + + private static void drawWrappedText(TextRenderer font, String text, float x, float y, float size, float maxW, int color, int maxLines) { + java.util.List lines = ClickGuiRenderer.wrapText(font, text == null ? "" : text, size, maxW, maxLines); + float cy = y; + for (String line : lines) { + ClickGuiRenderer.drawText(font, line, x, cy, size, color, false); + cy += UnifiedSettingsSkin.textHeight(font, size) + m(1f, 1f); + } + } + + private static void drawBindMarquee( + TextRenderer font, + String text, + float x, + float y, + float fullWidth, + float viewWidth, + float fontSize, + int color + ) { + final float speed = 22.0f; + final float gap = 14.0f; + final float pauseSec = 0.8f; + + if (text == null || text.isEmpty()) return; + if (speed <= 0.0f) { + ClickGuiRenderer.drawText(font, text, x, y, fontSize, color, false); + return; + } + + float cycleDistance = fullWidth + gap; + float cycleTime = pauseSec + cycleDistance / speed; + if (cycleTime <= 0.0f) { + ClickGuiRenderer.drawText(font, text, x, y, fontSize, color, false); + return; + } + + float now = net.minecraft.util.Util.getMillis() / 1000.0f; + float t = now % cycleTime; + + float offset = 0.0f; + if (t > pauseSec) { + offset = -(t - pauseSec) * speed; + } + + ClickGuiRenderer.drawText(font, text, x + offset, y, fontSize, color, false); + + if (cycleDistance > viewWidth * 0.5f) { + ClickGuiRenderer.drawText(font, text, x + offset + cycleDistance, y, fontSize, color, false); + } + } + + private record LayoutBoolean(String[] lines, float lineH, float labelY, float controlY, float rowH) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingsSkin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingsSkin.java new file mode 100644 index 0000000..7c46b90 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/settings/UnifiedSettingsSkin.java @@ -0,0 +1,250 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.settings; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +enum UnifiedSettingsSkin { + ; + static final float ROW_GAP = 3f; + + static int TEXT_PRIMARY = 0xFFE8ECF7; + static int TEXT_MUTED = 0xFFADB5C6; + static int TEXT_FAINT = 0x88ADB5C6; + + static int SURFACE = 0x301A1F27; + static int SURFACE_HOVER = 0x40222932; + static int SURFACE_SOFT = 0x28161B23; + static int SURFACE_GRADIENT_START = SURFACE_SOFT; + static int SURFACE_GRADIENT_END = SURFACE_HOVER; + static float SURFACE_GRADIENT_ANGLE = 90f; + static boolean SURFACE_GRADIENT_ENABLED = false; + static int CARD_GRADIENT_START = SURFACE_SOFT; + static int CARD_GRADIENT_END = SURFACE_HOVER; + static float CARD_GRADIENT_ANGLE = 90f; + static boolean CARD_GRADIENT_ENABLED = false; + static int STROKE_GRADIENT_START = TEXT_FAINT; + static int STROKE_GRADIENT_END = TEXT_FAINT; + static float STROKE_GRADIENT_ANGLE = 90f; + static boolean STROKE_GRADIENT_ENABLED = false; + + static int ACCENT = 0xFF9747FF; + static int ACCENT_SOFT = 0x629747FF; + static int ACCENT_GRADIENT_START = ACCENT; + static int ACCENT_GRADIENT_END = ACCENT_SOFT; + static float ACCENT_GRADIENT_ANGLE = 45f; + static int CHECK_COLOR = 0xFFEFF3FF; + + static void syncTheme() { + Themes.Theme theme = Theme.theme(); + if (theme == null) return; + + int accent = forceAlpha(theme.accent(), 255); + int accentSoft = forceAlpha(theme.accentSoft(), 255); + int surface = forceAlpha(theme.surface(), 255); + int hover = forceAlpha(theme.surfaceHover(), 255); + + TEXT_PRIMARY = forceAlpha(theme.textPrimary(), 255); + TEXT_MUTED = withAlpha(theme.textMuted(), 210); + TEXT_FAINT = withAlpha(theme.textMuted(), 135); + + ACCENT = accent; + ACCENT_SOFT = withAlpha(accentSoft, 135); + CHECK_COLOR = ClickGuiRenderer.mixColor(TEXT_PRIMARY, accent, 0.10f); + + SURFACE = withAlpha(ClickGuiRenderer.mixColor(surface, accent, 0.055f), 82); + SURFACE_HOVER = withAlpha(ClickGuiRenderer.mixColor(hover, accent, 0.095f), 104); + SURFACE_SOFT = withAlpha(ClickGuiRenderer.mixColor(surface, accent, 0.040f), 56); + + Themes.ThemeEntry entry = Theme.currentEntry(); + Themes.GradientSpec surfaceGradient = entry == null ? null : entry.surfaceGradient(); + Themes.GradientSpec cardGradient = entry == null ? null : entry.cardGradient(); + Themes.GradientSpec strokeGradient = entry == null ? null : entry.strokeGradient(); + + SURFACE_GRADIENT_ENABLED = surfaceGradient != null && surfaceGradient.enabled(); + SURFACE_GRADIENT_START = SURFACE_GRADIENT_ENABLED + ? withAlpha(ClickGuiRenderer.mixColor(forceAlpha(surfaceGradient.start(), 255), accent, 0.045f), 62) + : SURFACE_SOFT; + SURFACE_GRADIENT_END = SURFACE_GRADIENT_ENABLED + ? withAlpha(ClickGuiRenderer.mixColor(forceAlpha(surfaceGradient.end(), 255), accent, 0.075f), 98) + : SURFACE_HOVER; + SURFACE_GRADIENT_ANGLE = surfaceGradient == null ? 90f : surfaceGradient.angleDeg(); + + CARD_GRADIENT_ENABLED = cardGradient != null && cardGradient.enabled(); + CARD_GRADIENT_START = CARD_GRADIENT_ENABLED + ? withAlpha(forceAlpha(cardGradient.start(), 255), 86) + : SURFACE_SOFT; + CARD_GRADIENT_END = CARD_GRADIENT_ENABLED + ? withAlpha(forceAlpha(cardGradient.end(), 255), 118) + : SURFACE_HOVER; + CARD_GRADIENT_ANGLE = cardGradient == null ? 90f : cardGradient.angleDeg(); + + STROKE_GRADIENT_ENABLED = strokeGradient != null && strokeGradient.enabled(); + STROKE_GRADIENT_START = STROKE_GRADIENT_ENABLED ? forceAlpha(strokeGradient.start(), 255) : TEXT_FAINT; + STROKE_GRADIENT_END = STROKE_GRADIENT_ENABLED ? forceAlpha(strokeGradient.end(), 255) : TEXT_FAINT; + STROKE_GRADIENT_ANGLE = strokeGradient == null ? 90f : strokeGradient.angleDeg(); + + ACCENT_GRADIENT_START = CARD_GRADIENT_ENABLED ? forceAlpha(cardGradient.start(), 255) : ACCENT; + ACCENT_GRADIENT_END = CARD_GRADIENT_ENABLED ? forceAlpha(cardGradient.end(), 255) : forceAlpha(accentSoft, 255); + ACCENT_GRADIENT_ANGLE = CARD_GRADIENT_ENABLED ? cardGradient.angleDeg() : 45f; + } + + static boolean modules() { + return SettingRenderContext.current() == SettingRenderSurface.MODULES; + } + + static float scale() { + syncTheme(); + return Math.max(0.25f, Math.min(4.0f, SettingRenderContext.scale())); + } + + static float metric(float settings, float modules) { + return (modules() ? modules : settings) * scale(); + } + + static TextRenderer fontRegular() { + return Fonts.renderer("Onest", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + } + + static TextRenderer fontMedium() { + return Fonts.renderer("OnestMedium", FontInfo.Type.Regular, fontRegular()); + } + + static TextRenderer fontSemibold() { + return Fonts.renderer("OnestBold", FontInfo.Type.Regular, fontMedium()); + } + + static TextRenderer fontLight() { + return Fonts.renderer("OnestLight", FontInfo.Type.Regular, fontRegular()); + } + + static boolean inside(double mx, double my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + static int mix(int from, int to, float t) { + t = clamp01(t); + int a = Math.round(((from >>> 24) & 0xFF) * (1f - t) + ((to >>> 24) & 0xFF) * t); + int r = Math.round(((from >>> 16) & 0xFF) * (1f - t) + ((to >>> 16) & 0xFF) * t); + int g = Math.round(((from >>> 8) & 0xFF) * (1f - t) + ((to >>> 8) & 0xFF) * t); + int b = Math.round((from & 0xFF) * (1f - t) + (to & 0xFF) * t); + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + static int withAlpha(int color, float alpha01) { + int base = (color >>> 24) & 0xFF; + int a = Math.round(base * clamp01(alpha01)); + return (color & 0x00FFFFFF) | ((a & 0xFF) << 24); + } + + static int withAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + static int forceAlpha(int color, int alpha) { + return withAlpha(color, alpha); + } + + static String fit(TextRenderer font, String text, float size, float width) { + return ClickGuiRenderer.fitText(font, text, size, Math.max(1f, width)); + } + + static float textHeight(TextRenderer font, float size) { + return ClickGuiRenderer.textHeight(font, size); + } + + static float textWidth(TextRenderer font, String text, float size) { + return ClickGuiRenderer.textWidth(font, text == null ? "" : text, size); + } + + static void matte(float x, float y, float w, float h, float radius, int color) { + ClickGuiRenderer.drawRoundedRect(x, y, w, h, radius, color); + } + + static int surfaceGradientStart(float alpha) { + return withAlpha(SURFACE_GRADIENT_START, alpha); + } + + static int surfaceGradientEnd(float alpha) { + return withAlpha(SURFACE_GRADIENT_END, alpha); + } + + static int cardGradientStart(float alpha) { + return withAlpha(CARD_GRADIENT_START, alpha); + } + + static int cardGradientEnd(float alpha) { + return withAlpha(CARD_GRADIENT_END, alpha); + } + + static int strokeGradientStart(float alpha) { + return withAlpha(STROKE_GRADIENT_START, alpha); + } + + static int strokeGradientEnd(float alpha) { + return withAlpha(STROKE_GRADIENT_END, alpha); + } + + static int accentGradientStart(float alpha) { + return withAlpha(ACCENT_GRADIENT_START, alpha); + } + + static int accentGradientEnd(float alpha) { + return withAlpha(ACCENT_GRADIENT_END, alpha); + } + + static void drawSurface(float x, float y, float w, float h, float radius, float alpha) { + if (SURFACE_GRADIENT_ENABLED) { + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, radius, surfaceGradientStart(alpha), surfaceGradientEnd(alpha), SURFACE_GRADIENT_ANGLE); + } else { + ClickGuiRenderer.drawRoundedRect(x, y, w, h, radius, withAlpha(SURFACE_SOFT, alpha)); + } + } + + static void drawCard(float x, float y, float w, float h, float radius, float alpha) { + if (CARD_GRADIENT_ENABLED) { + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, radius, cardGradientStart(alpha), cardGradientEnd(alpha), CARD_GRADIENT_ANGLE); + } else { + ClickGuiRenderer.drawRoundedRect(x, y, w, h, radius, withAlpha(SURFACE_SOFT, alpha)); + } + } + + static void drawAccent(float x, float y, float w, float h, float radius, float alpha) { + ClickGuiRenderer.drawRoundedRectGradient(x, y, w, h, radius, accentGradientStart(alpha), accentGradientEnd(alpha), ACCENT_GRADIENT_ANGLE); + } + + static void drawStroke(float x, float y, float w, float h, float radius, float thickness, float alpha) { + if (STROKE_GRADIENT_ENABLED) { + ClickGuiRenderer.drawRoundedRectStrokeGradient(x, y, w, h, radius, thickness, strokeGradientStart(alpha), strokeGradientEnd(alpha), STROKE_GRADIENT_ANGLE); + } else { + ClickGuiRenderer.drawRoundedRectStroke(x, y, w, h, radius, thickness, withAlpha(TEXT_FAINT, alpha)); + } + } + + static void checkIcon(float x, float y, float size, float activeAnim, float hoverAnim) { + float active = clamp01(activeAnim); + float hover = clamp01(hoverAnim); + float alpha = 0.12f + active * 0.88f; + int tint = mix(CHECK_COLOR, 0xFFFFFFFF, hover * 0.35f); + int argb = withAlpha(tint, alpha); + Renderer2D.COLOR.svg("check", x, y, size, size, SvgRenderOptions.overrideColor(argb)); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiHintOverlay.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiHintOverlay.java new file mode 100644 index 0000000..91e578b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiHintOverlay.java @@ -0,0 +1,69 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.util; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +public enum ClickGuiHintOverlay { + ; + + private static TextRenderer comfortaa; + + public static void renderBottomLeft(float areaX, float areaY, float areaW, float areaH, float scale, float alpha, String... lines) { + if (lines == null || lines.length == 0 || areaW <= 0.5f || areaH <= 0.5f) return; + + TextRenderer font = comfortaa(); + float a = AnimationUtility.clamp(alpha, 0.0f, 1.0f); + if (a <= 0.001f) return; + + float ui = AnimationUtility.clamp(scale, 1.0f, 2.0f); + float size = 7.1f * ui; + float lineGap = 2.25f * ui; + float padX = 12.0f * ui; + float padY = 12.0f * ui; + float lineH = ClickGuiRenderer.textHeight(font, size); + + int visible = 0; + for (String line : lines) { + if (line != null && !line.isBlank()) visible++; + } + if (visible == 0) return; + + float totalH = visible * lineH + Math.max(0, visible - 1) * lineGap; + float x = areaX + padX; + float y = areaY + areaH - padY - totalH; + int text = withAlpha(0xFFEAF1F8, 0.84f * a); + int shadow = withAlpha(0xFF000000, 0.36f * a); + + int index = 0; + for (String line : lines) { + if (line == null || line.isBlank()) continue; + + float lineY = y + index * (lineH + lineGap); + ClickGuiRenderer.drawText(font, line, x + 0.7f * ui, lineY + 0.8f * ui, size, shadow, false); + ClickGuiRenderer.drawText(font, line, x, lineY, size, text, false); + index++; + } + } + + private static TextRenderer comfortaa() { + if (comfortaa == null) { + comfortaa = Fonts.renderer("Comfortaa", FontInfo.Type.Regular, ClickGuiRenderer.getInterRegular()); + } + return comfortaa; + } + + private static int withAlpha(int color, float alpha) { + int a = Math.max(0, Math.min(255, Math.round(((color >>> 24) & 0xFF) * alpha))); + return (color & 0x00FFFFFF) | (a << 24); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiI18n.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiI18n.java new file mode 100644 index 0000000..9952eca --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiI18n.java @@ -0,0 +1,33 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.util; + +import net.minecraft.locale.Language; + +import java.util.IllegalFormatException; +import java.util.Locale; + +public enum ClickGuiI18n { + ; + + public static String tr(String key, String fallback, Object... args) { + String pattern = Language.getInstance().getOrDefault(key, fallback); + if (args == null || args.length == 0) { + return pattern; + } + try { + return String.format(Locale.ROOT, pattern, args); + } catch (IllegalFormatException ignored) { + try { + return String.format(Locale.ROOT, fallback, args); + } catch (IllegalFormatException ignoredAgain) { + return fallback; + } + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiMath.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiMath.java new file mode 100644 index 0000000..77450d1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiMath.java @@ -0,0 +1,45 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.util; + +import dev.loki.lovisual.render.engine.animation.AnimationUtility; + +public enum ClickGuiMath { + ; + + public static float clamp(float v, float min, float max) { + return Math.max(min, Math.min(max, v)); + } + + public static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(max, v)); + } + + public static float lerp(float a, float b, float t) { + return AnimationUtility.lerp(a, b, t); + } + + public static float animateTo(float value, float target, float speed) { + return AnimationUtility.approach(value, target, speed); + } + + public static boolean insideRect(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + public static float rectOverlap(float ax, float ay, float aw, float ah, + float bx, float by, float bw, float bh) { + float ix = Math.max(ax, bx); + float iy = Math.max(ay, by); + float ax2 = Math.min(ax + aw, bx + bw); + float ay2 = Math.min(ay + ah, by + bh); + if (ax2 <= ix || ay2 <= iy) return 0f; + return (ax2 - ix) * (ay2 - iy); + } +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiRichTextRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiRichTextRenderer.java new file mode 100644 index 0000000..b532eae --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/util/ClickGuiRichTextRenderer.java @@ -0,0 +1,207 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.clickgui.util; + +import dev.loki.lovisual.features.gui.clickgui.ClickGuiRenderer; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextGlyphFallback; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public final class ClickGuiRichTextRenderer { + private static final RenderColor TMP = new RenderColor(255, 255, 255, 255); + + private ClickGuiRichTextRenderer() { + } + + public static void draw(Component component, + float x, + float y, + float maxWidth, + float size, + int fallbackColor, + float alpha, + boolean shadow) { + if (component == null || maxWidth <= 0f || alpha <= 0f) return; + float cursor = x; + float remaining = maxWidth; + for (Segment segment : flatten(component)) { + if (remaining <= 1f) break; + TextRenderer font = font(segment.style()); + int color = color(segment.style(), fallbackColor, alpha); + String text = fit(font, segment.text(), size, remaining); + drawString(ClickGuiRenderer.currentRenderer(), font, text, cursor, y, size, color, shadow); + float width = width(font, text, size); + cursor += width; + remaining -= width; + if (text.length() < segment.text().length()) break; + } + } + + public static float width(Component component, float size) { + float out = 0f; + for (Segment segment : flatten(component)) { + out += width(font(segment.style()), segment.text(), size); + } + return out; + } + + public static float height(float size) { + return height(font(Style.EMPTY), size); + } + + private static List flatten(Component text) { + List segments = new ArrayList<>(); + if (text == null) return segments; + text.visit((style, string) -> { + if (string != null && !string.isEmpty()) { + segments.add(new Segment(string, style == null ? Style.EMPTY : style)); + } + return Optional.empty(); + }, Style.EMPTY); + return segments; + } + + private static TextRenderer font(Style style) { + if (style != null && style.isBold()) { + return Fonts.renderer("InterMedium", FontInfo.Type.Regular, ClickGuiRenderer.getInterMedium()); + } + if (style != null && style.isItalic()) { + return Fonts.renderer("Inter", FontInfo.Type.Italic, ClickGuiRenderer.getInterRegular()); + } + return ClickGuiRenderer.getInterMedium(); + } + + private static int color(Style style, int fallbackColor, float alpha) { + int rgb = fallbackColor & 0x00FFFFFF; + TextColor textColor = style != null ? style.getColor() : null; + if (textColor != null) { + rgb = textColor.getValue() & 0x00FFFFFF; + } + int baseA = (fallbackColor >>> 24) & 0xFF; + int a = Math.max(0, Math.min(255, Math.round(baseA * alpha))); + return (a << 24) | rgb; + } + + private static String fit(TextRenderer font, String text, float size, float maxWidth) { + if (text == null || text.isEmpty()) return ""; + if (width(font, text, size) <= maxWidth) return text; + String suffix = "..."; + float suffixW = width(font, suffix, size); + int end = text.length(); + while (end > 0 && width(font, text.substring(0, end), size) + suffixW > maxWidth) { + end--; + } + return end <= 0 ? suffix : text.substring(0, end) + suffix; + } + + private static float width(TextRenderer font, String text, float size) { + if (font == null || text == null || text.isEmpty()) return 0f; + float scale = ClickGuiRenderer.scaleForSize(size); + float svgAdvance = svgGlyphSize(font, size); + TextRenderer current = null; + float width = 0f; + try { + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(font, cp)) { + if (current != null) { + current.end(); + current = null; + } + width += svgAdvance; + i += Character.charCount(cp); + continue; + } + String glyph = new String(Character.toChars(cp)); + TextRenderer next = TextGlyphFallback.rendererForGlyph(font, cp); + if (next != current) { + if (current != null) current.end(); + current = next; + current.begin(scale, true, false); + } + width += (float) current.getWidth(glyph, false); + i += Character.charCount(cp); + } + return width; + } finally { + if (current != null) current.end(); + } + } + + private static float height(TextRenderer font, float size) { + if (font == null) return size; + float scale = ClickGuiRenderer.scaleForSize(size); + font.begin(scale, true, false); + try { + return (float) font.getHeight(false); + } finally { + font.end(); + } + } + + private static void drawString(Renderer2D renderer, TextRenderer font, String text, float x, float y, float size, int argb, boolean shadow) { + if (font == null || text == null || text.isEmpty() || ((argb >>> 24) & 0xFF) <= 0) return; + float scale = ClickGuiRenderer.scaleForSize(size); + float svgSize = svgGlyphSize(font, size); + float svgY = y + (height(font, size) - svgSize) * 0.5f; + TMP.a = (argb >>> 24) & 0xFF; + TMP.r = (argb >>> 16) & 0xFF; + TMP.g = (argb >>> 8) & 0xFF; + TMP.b = argb & 0xFF; + TextRenderer current = null; + float cursorX = x; + try { + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(font, cp)) { + if (current != null) { + current.end(); + current = null; + } + String svgName = TextGlyphFallback.vanillaSvgName(cp); + if (renderer != null && svgName != null) { + renderer.svg(svgName, cursorX, svgY, svgSize, svgSize, + SvgRenderOptions.fromFile().withAlpha(((argb >>> 24) & 0xFF) / 255f)); + } + cursorX += svgSize; + i += Character.charCount(cp); + continue; + } + String glyph = new String(Character.toChars(cp)); + TextRenderer next = TextGlyphFallback.rendererForGlyph(font, cp); + if (next != current) { + if (current != null) current.end(); + current = next; + current.begin(scale, false, false); + } + cursorX = (float) current.render(glyph, cursorX, y, TMP, shadow); + i += Character.charCount(cp); + } + } finally { + if (current != null) current.end(); + } + } + + private static float svgGlyphSize(TextRenderer font, float size) { + return Math.max(1f, height(font, size)) * 0.92f; + } + + private record Segment(String text, Style style) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/AbstractHudElement.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/AbstractHudElement.java new file mode 100644 index 0000000..eb875c8 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/AbstractHudElement.java @@ -0,0 +1,32 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +/** + * Base class for non-draggable (vanilla/static) HUD elements with self-owned config. + */ +public abstract class AbstractHudElement extends BaseHudElement { + + protected AbstractHudElement() { + super(); + } + + protected AbstractHudElement(String id, String title, boolean defaultEnabled) { + super(id, title, defaultEnabled); + } + + @Override + public String getTranslationKeyPrefix() { + return "setting.hud_element." + getId(); + } + + @Override + public String getConfigName() { + return "hud_element_" + getId(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/BaseHudElement.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/BaseHudElement.java new file mode 100644 index 0000000..471884e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/BaseHudElement.java @@ -0,0 +1,480 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import dev.loki.lovisual.config.*; +import dev.loki.lovisual.events.impl.RenderPrewarmCollectEvent; +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import dev.loki.lovisual.config.*; +import dev.loki.lovisual.config.values.*; +import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import net.minecraft.client.resources.language.I18n; + +import java.util.*; +import java.util.function.Supplier; + +public abstract class BaseHudElement implements JsonConfigObject, ConfigNameProvider, SettingOwner, SettingDefProvider { + + private final String id; + private final String title; + private final BooleanValue enabled; + private final List settingDefs = new ArrayList<>(); + private final Map, SettingDef> settingDefsByValue = new IdentityHashMap<>(); + protected float x; + protected float y; + protected float width; + protected float height; + private boolean settingsInitialized = false; + private boolean previewEnabled = false; + + protected BaseHudElement() { + HudElementInfo info = getClass().getAnnotation(HudElementInfo.class); + if (info == null) { + throw new IllegalStateException(getClass().getName() + " must be annotated with @HudElementInfo"); + } + + this.id = info.id(); + this.title = info.displayName(); + this.enabled = new BooleanValue("enabled", info.enabledByDefault()); + } + + protected BaseHudElement(String id, String title, boolean defaultEnabled) { + this.id = id; + this.title = title; + this.enabled = new BooleanValue("enabled", defaultEnabled); + } + + private static String stripOwnerPrefix(String value, String owner) { + if (value == null || value.isBlank() || owner == null || owner.isBlank()) return value; + + String normalizedOwner = normalizePrefix(owner); + String lowerValue = value.toLowerCase(Locale.ROOT); + if (!lowerValue.startsWith(normalizedOwner)) return value; + + String tail = value.substring(normalizedOwner.length()); + if (tail.isEmpty()) return value; + + char first = tail.charAt(0); + if (first == '_' || first == '-' || first == '.') { + tail = tail.substring(1); + } else if (!Character.isUpperCase(first)) { + return value; + } + + return camelToSnake(tail); + } + + private static String normalizePrefix(String value) { + StringBuilder out = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = Character.toLowerCase(value.charAt(i)); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out.append(c); + } + } + return out.toString(); + } + + private static String camelToSnake(String value) { + if (value == null || value.isEmpty()) return value; + + StringBuilder out = new StringBuilder(value.length() + 4); + char previous = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '-' || c == '.' || c == ' ') { + c = '_'; + } + if (c == '_') { + if (!out.isEmpty() && out.charAt(out.length() - 1) != '_') { + out.append('_'); + } + previous = '_'; + continue; + } + if (Character.isUpperCase(c) && i > 0 && previous != '_' && !Character.isUpperCase(previous)) { + out.append('_'); + } + out.append(Character.toLowerCase(c)); + previous = c; + } + return out.toString(); + } + + protected void defineSettings(List defs) { + } + + protected void onLoaded() { + } + + protected void collectExtraConfigValues(Map> map) { + } + + protected final BooleanValue bool(String configName, boolean def) { + return bool(configName, settingIdFromConfigName(configName), def); + } + + protected final BooleanValue bool(String configName, String settingId, boolean def) { + BooleanValue value = new BooleanValue(configName, def); + declare(value, SettingDef.bool(settingId, value)); + return value; + } + + protected final NumberValue num(String configName, int def, int min, int max) { + return num(configName, settingIdFromConfigName(configName), def, min, max); + } + + protected final NumberValue num(String configName, String settingId, int def, int min, int max) { + NumberValue value = new NumberValue<>(configName, def, min, max); + declare(value, SettingDef.number(settingId, value)); + return value; + } + + protected final NumberValue num(String configName, float def, float min, float max) { + return num(configName, settingIdFromConfigName(configName), def, min, max); + } + + protected final NumberValue num(String configName, String settingId, float def, float min, float max) { + NumberValue value = new NumberValue<>(configName, def, min, max); + declare(value, SettingDef.number(settingId, value)); + return value; + } + + protected final NumberValue num(String configName, double def, double min, double max) { + return num(configName, settingIdFromConfigName(configName), def, min, max); + } + + protected final NumberValue num(String configName, String settingId, double def, double min, double max) { + NumberValue value = new NumberValue<>(configName, def, min, max); + declare(value, SettingDef.number(settingId, value)); + return value; + } + + protected final ModeValue mode(String configName, String def, String... options) { + return mode(configName, settingIdFromConfigName(configName), def, options); + } + + protected final ModeValue mode(String configName, String settingId, String def, String... options) { + ModeValue value = new ModeValue(configName, def, options); + declare(value, SettingDef.mode(settingId, value)); + return value; + } + + protected final > EnumValue enumSetting(String configName, E def) { + return enumSetting(configName, settingIdFromConfigName(configName), def); + } + + protected final > EnumValue enumSetting(String configName, String settingId, E def) { + Class enumClass = def.getDeclaringClass(); + EnumValue value = new EnumValue<>(configName, def, enumClass); + declare(value, SettingDef.mode(settingId, value)); + return value; + } + + protected final > EnumValue enumSetting(String configName, E def, Class enumClass) { + return enumSetting(configName, settingIdFromConfigName(configName), def, enumClass); + } + + protected final > EnumValue enumSetting(String configName, String settingId, E def, Class enumClass) { + EnumValue value = new EnumValue<>(configName, def, enumClass); + declare(value, SettingDef.mode(settingId, value)); + return value; + } + + @SafeVarargs + protected final > EnumValue enumSetting(String configName, E def, E... options) { + return enumSetting(configName, settingIdFromConfigName(configName), def, options); + } + + @SafeVarargs + protected final > EnumValue enumSetting(String configName, String settingId, E def, E... options) { + EnumValue value = new EnumValue<>(configName, def, options); + declare(value, SettingDef.mode(settingId, value)); + return value; + } + + protected final RGBAColorValue color(String configName, String defHex) { + return color(configName, settingIdFromConfigName(configName), defHex); + } + + protected final RGBAColorValue color(String configName, String settingId, String defHex) { + RGBAColorValue value = new RGBAColorValue(configName, defHex); + declare(value, SettingDef.color(settingId, value)); + return value; + } + + protected final RGBColorValue colorNoAlpha(String configName, String defHex) { + return colorNoAlpha(configName, settingIdFromConfigName(configName), defHex); + } + + protected final RGBColorValue colorNoAlpha(String configName, String settingId, String defHex) { + RGBColorValue value = new RGBColorValue(configName, defHex); + declare(value, SettingDef.colorNoAlpha(settingId, value)); + return value; + } + + protected final SetValue textList(String configName) { + return textList(configName, settingIdFromConfigName(configName), TextListSetting.PickerMode.ALL); + } + + protected final SetValue textList(String configName, String settingId, TextListSetting.PickerMode pickerMode) { + SetValue value = new SetValue(configName); + declare(value, SettingDef.textList(settingId, value, pickerMode)); + return value; + } + + protected final KeyBindValue bind(String configName, String def, BindMode mode) { + return bind(configName, settingIdFromConfigName(configName), def, mode); + } + + protected final KeyBindValue bind(String configName, String settingId, String def, BindMode mode) { + KeyBindValue value = new KeyBindValue(configName, def); + declare(value, SettingDef.bind(settingId, value, mode)); + return value; + } + + protected final BooleanMapValue group(String configName, String settingId, Map defaults) { + BooleanMapValue value = new BooleanMapValue(configName, defaults); + declare(value, SettingDef.group(settingId, value)); + return value; + } + + protected final > V visibleWhen(V value, Supplier supplier) { + SettingDef def = settingDefsByValue.get(value); + if (def != null) { + def.visibleWhen(supplier); + } + return value; + } + + protected final > V common(V value, String commonI18nKey) { + SettingDef def = settingDefsByValue.get(value); + if (def != null && commonI18nKey != null && !commonI18nKey.isBlank()) { + def.common(commonI18nKey); + } + return value; + } + + protected final > V declareSetting(V value, SettingDef def) { + declare(value, def); + return value; + } + + protected final SettingDef settingDef(ConfigValue value) { + return settingDefsByValue.get(value); + } + + protected final SettingDef enabledSettingDef() { + return SettingDef.bool(enabled); + } + + public void collectRenderPrewarm(RenderPrewarmCollectEvent event) { + if (event == null) return; + event.font("Inter", FontInfo.Type.Regular) + .font("Inter", FontInfo.Type.Bold) + .font("InterMedium", FontInfo.Type.Regular) + .font("OnestMedium", FontInfo.Type.Regular) + .font("Onest", FontInfo.Type.Regular) + .font("Onest", FontInfo.Type.Bold) + .font("OnestMedium", FontInfo.Type.Regular) + .font("OnestBold", FontInfo.Type.Regular) + .font("Iosevka", FontInfo.Type.Regular) + .font("Iosevka", FontInfo.Type.Bold) + .font("Comfortaa", FontInfo.Type.Regular) + .font("Icons", FontInfo.Type.Regular) + .font("IconsNur", FontInfo.Type.Regular) + .font("WeatherIcons", FontInfo.Type.Regular) + .font("MediaPlayer", FontInfo.Type.Regular); + } + + public void onTick() { + } + + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + } + + public void renderEngineForeground(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + } + + public boolean usesEngineRenderer() { + return false; + } + + public boolean isMouseOverInteractive(float mx, float my) { + return false; + } + + public boolean onMouseClicked(float mx, float my, int button) { + return false; + } + + public HudPhase getHudPhase() { + return HudPhase.AFTER_SUBTITLES; + } + + public HudRenderSpace getRenderSpace() { + return HudRenderSpace.UNSCALED_LOGICAL; + } + + public int getRenderOrder() { + return 0; + } + + public final void postInit() { + ensureSettingsInitialized(); + ConfigSerializer.load(this); + onLoaded(); + } + + public final String getId() { + return id; + } + + public final String getTitle() { + return translateTitle(title, getTranslationKeyPrefix(), id); + } + + private static String translateTitle(String fallback, String prefix, String id) { + List keys = new ArrayList<>(3); + if (prefix != null && prefix.startsWith("setting.draggable.")) { + keys.add("hud.draggable." + id); + keys.add("hud.draggable." + id + ".title"); + } else if (prefix != null && prefix.startsWith("setting.hud_element.")) { + keys.add("hud.static." + id); + keys.add("hud.static." + id + ".title"); + } + keys.add("hud." + id); + for (String key : keys) { + if (key == null || key.isBlank()) continue; + String translated = I18n.get(key); + if (!translated.equals(key)) { + return translated; + } + } + return fallback == null ? "" : fallback; + } + + public boolean isEnabled() { + return enabled.get() || previewEnabled; + } + + public void setEnabled(boolean value) { + enabled.set(value); + ConfigSerializer.requestSave(this); + } + + public void setRuntimeEnabledTransient(boolean value) { + enabled.set(value); + } + + public void setPreviewEnabled(boolean value) { + previewEnabled = value; + } + + public BooleanValue enabledValue() { + return enabled; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public float getWidth() { + return width; + } + + public float getHeight() { + return height; + } + + public void moveTo(float x, float y) { + this.x = x; + this.y = y; + } + + protected final void setBounds(float x, float y, float width, float height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public boolean contains(float mx, float my) { + return mx >= x && mx <= x + width && my >= y && my <= y + height; + } + + @Override + public final String name() { + return id; + } + + @Override + public final void saveConfig() { + ConfigSerializer.requestSave(this); + } + + @Override + public final List getSettingDefs() { + ensureSettingsInitialized(); + return new ArrayList<>(settingDefs); + } + + @Override + public List> getConfigValues() { + ensureSettingsInitialized(); + Map> map = new LinkedHashMap<>(); + map.put(enabled.getName(), enabled); + collectExtraConfigValues(map); + for (SettingDef def : settingDefs) { + ConfigValue v = def.value(); + if (v != null) { + map.put(v.getName(), v); + } + } + return new ArrayList<>(map.values()); + } + + private void ensureSettingsInitialized() { + if (settingsInitialized) return; + settingsInitialized = true; + defineSettings(settingDefs); + SettingDef.applyI18nAnnotations(this, settingDefs); + } + + private void declare(ConfigValue value, SettingDef def) { + settingDefs.add(def); + settingDefsByValue.put(value, def); + } + + private String settingIdFromConfigName(String configName) { + if (configName == null || configName.isBlank()) return configName; + + String tail = stripOwnerPrefix(configName, getId()); + tail = stripOwnerPrefix(tail, getClass().getSimpleName()); + return tail; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/CooldownRender.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/CooldownRender.java new file mode 100644 index 0000000..5e66d71 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/CooldownRender.java @@ -0,0 +1,44 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.network.chat.Component; + +public enum CooldownRender { + ; + private static final Minecraft mc = Minecraft.getInstance(); + private static final float SCALE = 0.75f; // немного меньше для аккуратного влезания в слот + + public static void renderTime(GuiGraphicsExtractor ctx, float progress, int x, int y, float secondsLeft) { + if (mc.font == null || ctx == null) return; + + // формируем текст + String textStr = (secondsLeft >= 10) + ? ((int) secondsLeft) + "с" + : String.format("%.1fс", secondsLeft); + Component text = Component.literal(textStr); + + int textWidth = mc.font.width(text); + int textHeight = mc.font.lineHeight; + + // центрирование по 16x16 + float centerX = x + 8 - (textWidth * SCALE / 2f); + float centerY = y + 8 - (textHeight * SCALE / 2f); + + var matrices = ctx.pose(); + matrices.pushMatrix(); + matrices.translate(centerX, centerY); // правильный вызов: только x и y + matrices.scale(SCALE, SCALE); + + ctx.text(mc.font, text, 0, 0, 0xFFFFFFFF, true); + + matrices.popMatrix(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorX.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorX.java new file mode 100644 index 0000000..b77dc59 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorX.java @@ -0,0 +1,15 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +public enum HudAnchorX { + FREE, + LEFT, + CENTER, + RIGHT +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorY.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorY.java new file mode 100644 index 0000000..35b8de1 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudAnchorY.java @@ -0,0 +1,15 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +public enum HudAnchorY { + FREE, + TOP, + CENTER, + BOTTOM +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementAutoLoader.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementAutoLoader.java new file mode 100644 index 0000000..e266807 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementAutoLoader.java @@ -0,0 +1,125 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ScanResult; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public enum HudElementAutoLoader { + ; + + public static void load(String basePackage) { + load(basePackage, true, true); + } + + public static void loadDraggable(String basePackage) { + load(basePackage, true, false); + } + + public static void loadStatic(String basePackage) { + load(basePackage, false, true); + } + + private static void load(String basePackage, boolean includeDraggable, boolean includeStatic) { + List elements = new ArrayList<>(); + + try (ScanResult scan = new ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .acceptPackages(basePackage) + .scan()) { + for (ClassInfo info : scan.getClassesWithAnnotation(HudElementRegister.class.getName())) { + addCandidate(elements, info); + } + for (ClassInfo info : scan.getClassesWithAnnotation(HudElementInfo.class.getName())) { + addCandidate(elements, info); + } + } + + elements.sort(Comparator + .comparingInt(RegisteredHudElement::order) + .thenComparing(element -> element.type().getName())); + + for (RegisteredHudElement element : elements) { + register(element.type(), includeDraggable, includeStatic); + } + } + + private static void register(Class type, boolean includeDraggable, boolean includeStatic) { + try { + Object instance = instanceFor(type); + + if (instance instanceof DraggableHudElement draggable) { + if (!includeDraggable) return; + DraggableHudElementRegistry.register(draggable); + return; + } + + if (instance instanceof AbstractHudElement hudElement) { + if (!includeStatic) return; + StaticHudElementRegistry.register(hudElement); + return; + } + + DebugLog.config("Skipping @HudElementRegister non-HUD element: %s", type.getName()); + } catch (Throwable t) { + DebugLog.error("Failed to load HUD element: %s", t, type.getName()); + } + } + + private static void addCandidate(List elements, ClassInfo info) { + if (info.isAbstract()) return; + + try { + Class type = info.loadClass(); + int order = 0; + HudElementInfo elementInfo = type.getAnnotation(HudElementInfo.class); + if (elementInfo != null) { + order = elementInfo.order(); + } else { + HudElementRegister register = type.getAnnotation(HudElementRegister.class); + if (register == null) return; + order = register.order(); + } + + elements.add(new RegisteredHudElement(type, order)); + } catch (Throwable t) { + DebugLog.error("Failed to inspect HUD element: %s", t, info.getName()); + } + } + + private static Object instanceFor(Class type) throws ReflectiveOperationException { + for (Field field : type.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers())) continue; + if (!type.isAssignableFrom(field.getType())) continue; + + field.setAccessible(true); + Object instance = field.get(null); + if (instance != null) return instance; + } + + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private record RegisteredHudElement(Class type, int order) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementInfo.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementInfo.java new file mode 100644 index 0000000..7207db2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementInfo.java @@ -0,0 +1,28 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import dev.loki.lovisual.events.UsedImplicitly; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@UsedImplicitly +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface HudElementInfo { + String id(); + + String displayName(); + + boolean enabledByDefault() default false; + + int order() default 0; +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementRegister.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementRegister.java new file mode 100644 index 0000000..f55b87c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElementRegister.java @@ -0,0 +1,22 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import dev.loki.lovisual.events.UsedImplicitly; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@UsedImplicitly +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface HudElementRegister { + int order() default 0; +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElements.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElements.java new file mode 100644 index 0000000..bf5dd56 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudElements.java @@ -0,0 +1,26 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +/** + * HUD widget bootstrapper. + */ +public enum HudElements { + ; + + private static boolean initialized = false; + + public static void init() { + if (initialized) return; + initialized = true; + + HudGlobalConfig.get(); + + HudElementAutoLoader.loadDraggable("dev.loki.lovisual.features.gui.hud.draggable.impl"); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudGlobalConfig.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudGlobalConfig.java new file mode 100644 index 0000000..1dcbe7f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudGlobalConfig.java @@ -0,0 +1,132 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import dev.loki.lovisual.config.*; +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.config.*; +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.render.engine.math.HudScale; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Global HUD config shared by all widgets. + */ +public final class HudGlobalConfig implements JsonConfigObject, ConfigNameProvider, SettingOwner, SettingDefProvider { + + public static final HudGlobalConfig INSTANCE = new HudGlobalConfig(); + private final EnumValue headerMode = + new EnumValue<>("headers", HeaderMode.CHAT, HeaderMode.ALWAYS, HeaderMode.CHAT, HeaderMode.NEVER); + private final NumberValue blurRadius = + new NumberValue<>("blur_radius", 12, 4, 72); + private final List settingDefs = new ArrayList<>(); + private final List globalPanelDefs = new ArrayList<>(); + private boolean settingsInitialized = false; + private HudGlobalConfig() { + ensureSettingsInitialized(); + deleteLegacyGlobalFile(); + } + + private void deleteLegacyGlobalFile() { + try { + Files.deleteIfExists(ConfigPaths.root().resolve("hud").resolve("global.json")); + } catch (IOException ignored) { + // Best-effort cleanup only. Global HUD settings now live as code defaults. + } + } + + public static HudGlobalConfig get() { + return INSTANCE; + } + + public float getFontSize() { + return HudScale.hudFontSize(); + } + + public HeaderMode getHeaderMode() { + return headerMode.get(); + } + + public int getBlurRadius() { + return blurRadius.get(); + } + + @Override + public String name() { + return "hud"; + } + + @Override + public String getTranslationKeyPrefix() { + return "setting.hud"; + } + + @Override + public void saveConfig() { + // Global HUD values are code defaults only; no hud/global.json is written. + } + + @Override + public String getConfigName() { + return "hud"; + } + + @Override + public List getSettingDefs() { + ensureSettingsInitialized(); + return new ArrayList<>(settingDefs); + } + + public List getGlobalPanelSettingDefs() { + ensureSettingsInitialized(); + return new ArrayList<>(globalPanelDefs); + } + + @Override + public List> getConfigValues() { + ensureSettingsInitialized(); + Map> map = new LinkedHashMap<>(); + map.put(headerMode.getName(), headerMode); + map.put(blurRadius.getName(), blurRadius); + return new ArrayList<>(map.values()); + } + + private void ensureSettingsInitialized() { + if (settingsInitialized) return; + settingsInitialized = true; + SettingDef headersDef = SettingDef.mode(headerMode); + + settingDefs.add(headersDef); + globalPanelDefs.add(headersDef); + SettingDef.applyI18nAnnotations(this, settingDefs); + } + + public enum HeaderMode implements EnumValue.IdProvider { + ALWAYS("always"), + CHAT("chat"), + NEVER("never"); + + private final String id; + + HeaderMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderSpace.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderSpace.java new file mode 100644 index 0000000..41de97b --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderSpace.java @@ -0,0 +1,14 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +public enum HudRenderSpace { + UNSCALED, + UNSCALED_LOGICAL, + SCALED +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderUtil.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderUtil.java new file mode 100644 index 0000000..d27f34d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudRenderUtil.java @@ -0,0 +1,272 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; + +/** + * Shared helpers for HUD widget visuals. + */ +public enum HudRenderUtil { + ; + + + public static final int GLASS_BG = 0x660C0C0C; + public static final float GLASS_PANEL_FILL_ALPHA = 0.165f; + public static final float GLASS_SMALL_FILL_ALPHA = 0.212f; + + public static int glassBackground() { + return GLASS_BG; + } + + public static int glassBackground(float alphaFactor) { + return scaleAlpha(GLASS_BG, alphaFactor); + } + + public static int glassBackgroundRgb() { + return GLASS_BG & 0x00FFFFFF; + } + + public static int glassPanelBackground(float alphaFactor) { + return scaleAlpha(GLASS_BG, alphaFactor * GLASS_PANEL_FILL_ALPHA); + } + + public static int glassSmallBackground(float alphaFactor) { + return scaleAlpha(GLASS_BG, alphaFactor * GLASS_SMALL_FILL_ALPHA); + } + + public static void drawLiquidGlass(float x, + float y, + float w, + float h, + float radius, + float scale, + boolean large, + float alpha) { + drawLiquidGlass(x, y, w, h, radius, scale, large, alpha, alpha); + } + + public static void drawLiquidGlass(float x, + float y, + float w, + float h, + float radius, + float scale, + boolean large, + float blurAlpha, + float glassAlpha) { + if (blurAlpha <= 0.001f && glassAlpha <= 0.001f) return; + + float blurStrength = AnimationUtility.clamp(blurAlpha, 0.0f, 1.0f); + float materialAlpha = AnimationUtility.clamp(glassAlpha, 0.0f, 1.0f); + float safeScale = Math.max(0.001f, scale); + + float fresnelPower = large ? -22.0f : -18.0f; + float thickness = (large ? 13.0f : 11.0f) * safeScale; + float baseAlpha = large ? 0.74f : 0.82f; + float fresnelMix = large ? 0.34f : 0.38f; + float distort = large ? 0.155f : 0.135f; + + Renderer2D.COLOR.liquidGlassRect( + x, + y, + w, + h, + radius, + thickness, + 0xFFFFFFFF, + materialAlpha, + blurStrength, + fresnelPower, + 1.0f, + baseAlpha, + fresnelMix, + distort * materialAlpha, + 0.0f + ); + } + + + public static void drawLiquidGlassCorners(float x, + float y, + float w, + float h, + float radiusTL, + float radiusTR, + float radiusBR, + float radiusBL, + float scale, + boolean large, + float alpha) { + drawLiquidGlassCorners( + x, + y, + w, + h, + radiusTL, + radiusTR, + radiusBR, + radiusBL, + scale, + large, + alpha, + 0xFFFFFFFF + ); + } + + public static void drawLiquidGlassCorners(float x, + float y, + float w, + float h, + float radiusTL, + float radiusTR, + float radiusBR, + float radiusBL, + float scale, + boolean large, + float alpha, + int tintArgb) { + drawLiquidGlassCorners(x, y, w, h, radiusTL, radiusTR, radiusBR, radiusBL, scale, large, alpha, alpha, tintArgb); + } + + public static void drawLiquidGlassCorners(float x, + float y, + float w, + float h, + float radiusTL, + float radiusTR, + float radiusBR, + float radiusBL, + float scale, + boolean large, + float blurAlpha, + float glassAlpha, + int tintArgb) { + if (blurAlpha <= 0.001f && glassAlpha <= 0.001f) return; + + float blurStrength = AnimationUtility.clamp(blurAlpha, 0.0f, 1.0f); + float materialAlpha = AnimationUtility.clamp(glassAlpha, 0.0f, 1.0f); + float safeScale = Math.max(0.001f, scale); + + float fresnelPower = large ? -22.0f : -18.0f; + float thickness = (large ? 13.0f : 11.0f) * safeScale; + float baseAlpha = large ? 0.74f : 0.82f; + float fresnelMix = large ? 0.34f : 0.38f; + float distort = large ? 0.155f : 0.135f; + + Renderer2D.COLOR.liquidGlassRectCorners( + x, + y, + w, + h, + radiusTL, + radiusTR, + radiusBR, + radiusBL, + thickness, + tintArgb, + materialAlpha, + blurStrength, + fresnelPower, + 1.0f, + baseAlpha, + fresnelMix, + distort * materialAlpha, + 0.0f + ); + } + + public static void drawHudBackground(Renderer2D renderer, + float x, float y, + float w, float h, + float radius, float softness, + int solidColor, + boolean useGradient) { + if (renderer == null) return; + if (useGradient) { + int alpha = (solidColor >>> 24) & 0xFF; + Themes.ThemeEntry entry = Theme.currentEntry(); + Themes.GradientSpec gradient = entry != null ? entry.windowGradient() : null; + if (gradient != null && gradient.enabled()) { + int start = setAlpha(gradient.start(), alpha); + int end = setAlpha(gradient.end(), alpha); + renderer.roundedRectGradient(x, y, w, h, radius, softness, start, end, gradient.angleDeg()); + } else { + int start = mixRgb(solidColor, 0xFFFFFF, 0.06f); + int end = mixRgb(solidColor, 0x000000, 0.08f); + renderer.roundedRectGradient(x, y, w, h, radius, softness, start, end, 90f); + } + } else { + renderer.roundedRect(x, y, w, h, radius, softness, solidColor); + } + } + + public static int setAlpha(int color, int alpha) { + int a = Math.max(0, Math.min(255, alpha)); + return (color & 0x00FFFFFF) | (a << 24); + } + + public static int scaleAlpha(int color, float factor) { + factor = Math.max(0f, Math.min(1f, factor)); + int a = (color >>> 24) & 0xFF; + int na = (int) (a * factor); + return (color & 0x00FFFFFF) | ((na & 0xFF) << 24); + } + + public static int mixColor(int from, int to, float t) { + t = Math.max(0f, Math.min(1f, t)); + int a = (int) (((from >>> 24) & 0xFF) * (1 - t) + ((to >>> 24) & 0xFF) * t); + int r = (int) (((from >>> 16) & 0xFF) * (1 - t) + ((to >>> 16) & 0xFF) * t); + int g = (int) (((from >>> 8) & 0xFF) * (1 - t) + ((to >>> 8) & 0xFF) * t); + int b = (int) (((from) & 0xFF) * (1 - t) + ((to) & 0xFF) * t); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + public static int mixRgb(int baseColor, int targetRgb, float t) { + t = Math.max(0f, Math.min(1f, t)); + int a = (baseColor >>> 24) & 0xFF; + int r = (baseColor >>> 16) & 0xFF; + int g = (baseColor >>> 8) & 0xFF; + int b = baseColor & 0xFF; + int tr = (targetRgb >>> 16) & 0xFF; + int tg = (targetRgb >>> 8) & 0xFF; + int tb = targetRgb & 0xFF; + int nr = (int) (r * (1 - t) + tr * t); + int ng = (int) (g * (1 - t) + tg * t); + int nb = (int) (b * (1 - t) + tb * t); + return (a << 24) | (nr << 16) | (ng << 8) | nb; + } + + public static float animateVisibility(float current, boolean visible) { + float target = visible ? 1.0f : 0.0f; + float duration = visible ? 0.30f : 0.20f; + float step = duration <= 0.0f ? 1.0f : AnimationUtility.clamp01(AnimationUtility.deltaTime() / duration); + if (current < target) { + current = Math.min(target, current + step); + } else { + current = Math.max(target, current - step); + } + return AnimationUtility.snap(current, target, 0.001f); + } + + public static float visibilityScale(float progress) { + return AnimationUtility.easeOutBack(progress, 1.0f); + } + + public static float animateDimension(float current, float target) { + if (current < 0.0f) return target; + float progress = AnimationUtility.clamp01(AnimationUtility.deltaTime() / 0.10f); + float eased = 1.0f - (float) Math.pow(1.0f - progress, 4.0f); + float next = AnimationUtility.lerp(current, target, eased); + return AnimationUtility.snap(next, target, 0.05f); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudTextEffects.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudTextEffects.java new file mode 100644 index 0000000..b039c74 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/HudTextEffects.java @@ -0,0 +1,117 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +/** + * Lightweight animated gradient text effects for HUD labels/icons. + */ +public enum HudTextEffects { + ; + + public static boolean render(TextRenderer renderer, + String text, + float x, + float y, + int baseColor, + Effect effect, + int speed, + float timeSec, + boolean shadow) { + if (renderer == null || text == null || text.isEmpty()) return false; + if (effect == null || effect == Effect.NONE) return false; + + int alpha = (baseColor >>> 24) & 0xFF; + if (alpha <= 0) return false; + + int black = (alpha << 24); + int white = (alpha << 24) | 0x00FFFFFF; + int darkColor = HudRenderUtil.mixColor(baseColor, black, 0.45f); + int lightColor = HudRenderUtil.mixColor(baseColor, white, 0.22f); + + float phaseStep = 0.55f; + float phaseShift = 0.85f; + float speedFactor = Math.max(0.1f, speed * 0.08f); + float tBase = timeSec * speedFactor; + + renderer.renderGradient(text, x, y, (idx, cp, gx, out) -> { + float p = tBase + idx * phaseStep; + float t0; + float t1; + if (effect == Effect.FLOW) { + float flow = tBase + idx * 0.35f; + t0 = 0.5f + 0.5f * (float) Math.sin(flow); + t1 = 0.5f + 0.5f * (float) Math.sin(flow + 1.6f); + } else if (effect == Effect.PULSE) { + float pulse = 0.5f + 0.5f * (float) Math.sin(tBase * 1.6f); + t0 = pulse; + t1 = pulse; + } else if (effect == Effect.STRIPE) { + float stripe = 0.5f + 0.5f * (float) Math.sin(p * 2.4f); + t0 = stripe; + t1 = 1.0f - stripe; + } else { + t0 = 0.5f + 0.5f * (float) Math.sin(p); + t1 = 0.5f + 0.5f * (float) Math.sin(p + phaseShift); + } + out[0] = HudRenderUtil.mixColor(darkColor, lightColor, t0); + out[1] = HudRenderUtil.mixColor(darkColor, lightColor, t1); + }, shadow); + return true; + } + + public static int animatedColor(int baseColor, + Effect effect, + int speed, + float timeSec, + float phase) { + if (effect == null || effect == Effect.NONE) return baseColor; + int alpha = (baseColor >>> 24) & 0xFF; + if (alpha <= 0) return baseColor; + + int black = (alpha << 24); + int white = (alpha << 24) | 0x00FFFFFF; + int darkColor = HudRenderUtil.mixColor(baseColor, black, 0.45f); + int lightColor = HudRenderUtil.mixColor(baseColor, white, 0.22f); + + float speedFactor = Math.max(0.1f, speed * 0.08f); + float tBase = timeSec * speedFactor + phase; + float t; + if (effect == Effect.FLOW) { + t = 0.5f + 0.5f * (float) Math.sin(tBase); + } else if (effect == Effect.PULSE) { + t = 0.5f + 0.5f * (float) Math.sin(tBase * 1.6f); + } else if (effect == Effect.STRIPE) { + t = 0.5f + 0.5f * (float) Math.sin(tBase * 2.4f); + } else { + t = 0.5f + 0.5f * (float) Math.sin(tBase); + } + return HudRenderUtil.mixColor(darkColor, lightColor, t); + } + + public enum Effect implements EnumValue.IdProvider { + NONE("None"), + MIX("Mix"), + FLOW("Flow"), + PULSE("Pulse"), + STRIPE("Stripe"); + + private final String id; + + Effect(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/WidgetAnchorSide.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/WidgetAnchorSide.java new file mode 100644 index 0000000..a13ae75 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/WidgetAnchorSide.java @@ -0,0 +1,16 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud; + +public enum WidgetAnchorSide { + NONE, + TOP, + BOTTOM, + LEFT, + RIGHT +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElement.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElement.java new file mode 100644 index 0000000..da8e2f0 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElement.java @@ -0,0 +1,229 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable; + +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.features.gui.hud.BaseHudElement; +import dev.loki.lovisual.features.gui.hud.HudAnchorX; +import dev.loki.lovisual.features.gui.hud.HudAnchorY; +import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide; +import lombok.Getter; + +import java.util.Map; + +/** + * Base draggable HUD element with self-owned config and layout. + */ +public abstract class DraggableHudElement extends BaseHudElement { + + private final DraggableLayoutValue layoutValue = new DraggableLayoutValue("layout"); + @Getter + private boolean positionInitialized = false; + @Getter + private HudAnchorX anchorX = HudAnchorX.FREE; + @Getter + private HudAnchorY anchorY = HudAnchorY.FREE; + @Getter + private float anchorOffsetX; + @Getter + private float anchorOffsetY; + @Getter + private String parentId; + @Getter + private WidgetAnchorSide parentSide = WidgetAnchorSide.NONE; + @Getter + private float parentOffsetX; + @Getter + private float parentOffsetY; + private long renderOrder; + + protected DraggableHudElement() { + super(); + } + + protected DraggableHudElement(String id, String title, boolean defaultEnabled) { + super(id, title, defaultEnabled); + } + + public DraggableLayoutValue layoutValue() { + return layoutValue; + } + + protected final void defaultLayout(float x, float y) { + defaultLayout(x, y, "FREE", "FREE"); + } + + protected final void defaultLayout(float x, float y, String anchorX, String anchorY) { + layoutValue.set(new DraggableLayoutValue.State( + x, + y, + parseDefaultAnchorX(anchorX), + parseDefaultAnchorY(anchorY), + null, + WidgetAnchorSide.NONE, + 0f, + 0f, + true + )); + } + + protected final void defaultLinkedLayout(float x, + float y, + String parentId, + String parentSide, + float parentOffsetX, + float parentOffsetY) { + layoutValue.set(new DraggableLayoutValue.State( + x, + y, + HudAnchorX.FREE, + HudAnchorY.FREE, + parentId, + parseDefaultParentSide(parentSide), + parentOffsetX, + parentOffsetY, + true + )); + } + + private static HudAnchorX parseDefaultAnchorX(String value) { + if (value == null || value.isBlank()) return HudAnchorX.FREE; + try { + return HudAnchorX.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return HudAnchorX.FREE; + } + } + + private static HudAnchorY parseDefaultAnchorY(String value) { + if (value == null || value.isBlank()) return HudAnchorY.FREE; + try { + return HudAnchorY.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return HudAnchorY.FREE; + } + } + + private static WidgetAnchorSide parseDefaultParentSide(String value) { + if (value == null || value.isBlank()) return WidgetAnchorSide.NONE; + try { + return WidgetAnchorSide.valueOf(value.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return WidgetAnchorSide.NONE; + } + } + + public void markPositionInitialized() { + positionInitialized = true; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public float getWidth() { + return width; + } + + public float getHeight() { + return height; + } + + public long getRuntimeRenderOrder() { + return renderOrder; + } + + public void moveTo(float x, float y) { + this.x = x; + this.y = y; + } + + public void setAnchors(HudAnchorX anchorX, HudAnchorY anchorY, float offsetX, float offsetY) { + this.anchorX = anchorX != null ? anchorX : HudAnchorX.FREE; + this.anchorY = anchorY != null ? anchorY : HudAnchorY.FREE; + this.anchorOffsetX = offsetX; + this.anchorOffsetY = offsetY; + } + + public void setParentAnchor(String parentId, + WidgetAnchorSide side, + float offsetX, + float offsetY) { + this.parentId = parentId; + this.parentSide = side != null ? side : WidgetAnchorSide.NONE; + this.parentOffsetX = offsetX; + this.parentOffsetY = offsetY; + } + + public void clearParentAnchor() { + this.parentId = null; + this.parentSide = WidgetAnchorSide.NONE; + this.parentOffsetX = 0f; + this.parentOffsetY = 0f; + } + + public void applyLayoutState(DraggableLayoutValue.State state) { + if (state == null) return; + setAnchors(state.anchorX(), state.anchorY(), state.x(), state.y()); + if (state.parentId() != null && state.parentSide() != WidgetAnchorSide.NONE) { + setParentAnchor(state.parentId(), state.parentSide(), state.parentOffsetX(), state.parentOffsetY()); + } else { + clearParentAnchor(); + } + } + + public DraggableLayoutValue.State snapshotLayout() { + return new DraggableLayoutValue.State( + anchorOffsetX, + anchorOffsetY, + anchorX, + anchorY, + parentId, + parentSide, + parentOffsetX, + parentOffsetY, + true + ); + } + + void markRendered(long order) { + this.renderOrder = order; + } + + /** + * Apply default position if no saved layout. + */ + public abstract void applyDefaultPosition(int screenW, int screenH); + + public boolean isDraggable() { + return true; + } + + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public String getTranslationKeyPrefix() { + return "setting.draggable." + getId(); + } + + @Override + public String getConfigName() { + return getId(); + } + + @Override + protected void collectExtraConfigValues(Map> map) { + map.put(layoutValue.getName(), layoutValue); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElementRegistry.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElementRegistry.java new file mode 100644 index 0000000..b4c8afe --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableHudElementRegistry.java @@ -0,0 +1,1459 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.gui.hud.*; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.render.engine.profiler.ProfilerPhase; +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.screens.ChatScreen; +import org.lwjgl.glfw.GLFW; +import dev.loki.lovisual.features.gui.chat.BetterChatRenderer; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.runtime.RuntimeGate; +import dev.loki.lovisual.render.helpers.SystemCursor; +import dev.loki.lovisual.util.input.KeyManager; +import dev.loki.lovisual.util.logging.DebugLog; + +import java.util.*; + +/** + * Реестр HUD-виджетов + drag-and-drop при открытом чате. + */ +public enum DraggableHudElementRegistry { + ; + + private static final List WIDGETS = new ArrayList<>(); + private static final float SNAP_DIST = 8f; + private static final float PARENT_SNAP_DIST = 10f; + private static final float PARENT_ALIGN_SNAP_DIST = 5f; + private static final float PARENT_GAP = 6f; + private static final int SNAP_PREVIEW_LINE = 0xFFFFFFFF; + private static final float DRAG_COORD_TEXT_SCALE = 0.85f; + private static final float DRAG_COORD_MARGIN = 4f; + private static final float LINK_WIRE_THICKNESS = 1.05f; + private static final float LINK_PREVIEW_THICKNESS = 1.55f; + private static DraggableHudElement dragging; + private static float dragOffsetX; + private static float dragOffsetY; + private static boolean draggingWithLeft; + private static boolean prevLeft; + private static boolean prevMiddle; + private static long renderOrderSeq; + private static boolean forceVisible; + private static boolean snapPreviewActive; + private static boolean snapPreviewHasX; + private static boolean snapPreviewHasY; + private static float snapPreviewLineX; + private static float snapPreviewLineY; + private static String editorWidgetId; + private static boolean hudCursorActive; + private static int lastScreenW = -1; + private static int lastScreenH = -1; + private static Themes.Theme cachedLinkTheme; + private static LinkWireColors cachedLinkColors; + + public static void register(DraggableHudElement widget) { + if (!WIDGETS.contains(widget)) { + widget.postInit(); + WIDGETS.add(widget); + DebugLog.config("[HUD] Registered widget: %s", widget.getId()); + } + } + + public static void clearRuntimeReferences() { + cancelDragging(); + WIDGETS.clear(); + editorWidgetId = null; + forceVisible = false; + cachedLinkTheme = null; + cachedLinkColors = null; + clearSnapPreview(); + } + + public static List getWidgets() { + return Collections.unmodifiableList(WIDGETS); + } + + public static DraggableHudElement getById(String id) { + return findById(id); + } + + public static boolean isEnabled(String id) { + if (!RuntimeGate.canRunHud()) return false; + DraggableHudElement w = findById(id); + return w != null && w.isEnabled(); + } + + public static T get(Class type) { + if (type == null) return null; + for (DraggableHudElement w : WIDGETS) { + if (type.isInstance(w)) { + return type.cast(w); + } + } + return null; + } + + public static T widget(Class type) { + return get(type); + } + + public static boolean isEnabled(Class type) { + if (!RuntimeGate.canRunHud()) return false; + DraggableHudElement w = get(type); + return w != null && w.isEnabled(); + } + + public static void tickAll() { + for (DraggableHudElement w : WIDGETS) { + w.onTick(); + } + } + + public static boolean isForceVisible() { + return forceVisible; + } + + public static void setForceVisible(boolean value) { + forceVisible = value; + } + + public static void setEditorWidgetId(String id) { + editorWidgetId = id; + } + + public static boolean hasEngineWork(HudPhase phase, boolean foreground) { + if (!RuntimeGate.canRunHud()) return false; + if (foreground && dragging != null) return true; + + for (DraggableHudElement w : WIDGETS) { + if (isRenderableCandidate(w, phase)) return true; + } + return false; + } + + public static boolean hasEngineWidgetWork(String id) { + if (!RuntimeGate.canRunHud()) return false; + if (id == null || id.isBlank()) return false; + if (editorWidgetId != null && !editorWidgetId.equals(id)) return false; + + DraggableHudElement w = findById(id); + if (w == null) return false; + if (!w.usesEngineRenderer()) return false; + boolean bypassEnabled = forceVisible && editorWidgetId != null && editorWidgetId.equals(id); + return bypassEnabled || w.isEnabled(); + } + + public static void renderAllEngine(HudPhase phase, + Renderer2D renderer, + TextRenderer textRenderer, + net.minecraft.client.gui.GuiGraphicsExtractor ctx, + float tickDelta) { + if (!RuntimeGate.canRunHud()) { + cancelDragging(); + return; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return; + boolean allowOnlyModuleList = false; + + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + handleScreenResize(screenW, screenH); + + List renderList = new ArrayList<>(); + for (DraggableHudElement w : WIDGETS) { + boolean singleEditorPreview = editorWidgetId != null; + if (singleEditorPreview && (editorWidgetId.isEmpty() || !editorWidgetId.equals(w.getId()))) { + continue; + } + boolean bypassEnabled = singleEditorPreview && forceVisible; + if (!bypassEnabled && !w.isEnabled()) { + continue; + } + if (!w.usesEngineRenderer()) { + continue; + } + if (w.getHudPhase() != phase) { + continue; + } + if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) { + continue; + } + ensurePositionInitialized(w, screenW, screenH); + if (allowOnlyModuleList && !"module_list".equals(w.getId())) { + continue; + } + renderList.add(w); + } + + for (DraggableHudElement w : renderList) { + if (w == dragging) continue; + if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) { + applyScreenAnchor(w, screenW, screenH); + } + } + for (DraggableHudElement w : renderList) { + if (w == dragging) continue; + if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) { + applyAnchors(w, screenW, screenH, new HashSet<>()); + } + } + + for (DraggableHudElement w : renderList) { + noteRendered(w); + try (ProfilerPhase.Scope widgetScope = + ProfilerPhase.scope("2d:widget:" + w.getId()); + RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget:" + w.getId())) { + w.renderEngine(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + } + } + + public static void renderAllEngineForeground(HudPhase phase, + Renderer2D renderer, + TextRenderer textRenderer, + net.minecraft.client.gui.GuiGraphicsExtractor ctx, + float tickDelta) { + if (!RuntimeGate.canRunHud()) { + cancelDragging(); + return; + } + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return; + boolean allowOnlyModuleList = false; + + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + handleScreenResize(screenW, screenH); + + for (DraggableHudElement w : WIDGETS) { + boolean singleEditorPreview = editorWidgetId != null; + if (singleEditorPreview && (editorWidgetId.isEmpty() || !editorWidgetId.equals(w.getId()))) { + continue; + } + boolean bypassEnabled = singleEditorPreview && forceVisible; + if (!bypassEnabled && !w.isEnabled()) { + continue; + } + if (!w.usesEngineRenderer()) { + continue; + } + if (w.getHudPhase() != phase) { + continue; + } + if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) { + continue; + } + if (allowOnlyModuleList && !"module_list".equals(w.getId())) { + continue; + } + try (ProfilerPhase.Scope widgetScope = + ProfilerPhase.scope("2d:widget_fg:" + w.getId()); + RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget_fg:" + w.getId())) { + w.renderEngineForeground(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + } + + renderDragOverlay(renderer, textRenderer, screenW, screenH); + BetterChatRenderer.renderCapturedTooltip(ctx); + if (phase == HudPhase.AFTER_SUBTITLES) { + float uiScale = HudScale.scale(fbw, fbh); + handleDragging(mc, screenW, screenH, uiScale); + } + } + + public static void renderEngineWidget(String id, + Renderer2D renderer, + TextRenderer textRenderer, + net.minecraft.client.gui.GuiGraphicsExtractor ctx, + float tickDelta) { + if (!RuntimeGate.canRunHud()) { + cancelDragging(); + return; + } + if (editorWidgetId != null && (!editorWidgetId.equals(id))) return; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return; + boolean isChat = ClientScreen.current() instanceof net.minecraft.client.gui.screens.ChatScreen; + boolean isInventory = ClientScreen.current() instanceof net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; + boolean allowOnlyModuleList = isInventory && ClientScreen.current() != null; + if (forceVisible) { + allowOnlyModuleList = false; + } + boolean shouldHideHud = !forceVisible && ClientScreen.current() != null && !isChat && !allowOnlyModuleList; + if (shouldHideHud) { + return; + } + + DraggableHudElement w = findById(id); + if (w == null) return; + if (!w.usesEngineRenderer()) return; + boolean bypassEnabled = forceVisible && editorWidgetId != null && editorWidgetId.equals(id); + if (!bypassEnabled && !w.isEnabled()) { + return; + } + if (allowOnlyModuleList && !"module_list".equals(w.getId())) { + return; + } + + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + handleScreenResize(screenW, screenH); + + ensurePositionInitialized(w, screenW, screenH); + if (w != dragging) { + if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) { + applyScreenAnchor(w, screenW, screenH); + } else { + applyAnchors(w, screenW, screenH, new HashSet<>()); + } + } + + noteRendered(w); + try (ProfilerPhase.Scope widgetScope = + ProfilerPhase.scope("2d:widget:" + w.getId()); + RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget:" + w.getId())) { + w.renderEngine(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + try (ProfilerPhase.Scope widgetScope = + ProfilerPhase.scope("2d:widget_fg:" + w.getId()); + RenderProfiler2D.Section ignored = RenderProfiler2D.section("widget_fg:" + w.getId())) { + w.renderEngineForeground(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + } + + private static void ensurePositionInitialized(DraggableHudElement w, int screenW, int screenH) { + if (w.isPositionInitialized()) return; + DraggableLayoutValue.State saved = w.layoutValue().get(); + if (saved != null) { + DraggableLayoutValue.State resolved = saved; + if (!saved.logical()) { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.getWindow() != null) { + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + float uiScale = HudScale.scale(fbw, fbh); + float nx = HudScale.toVirtual(saved.x(), uiScale); + float ny = HudScale.toVirtual(saved.y(), uiScale); + float npx = HudScale.toVirtual(saved.parentOffsetX(), uiScale); + float npy = HudScale.toVirtual(saved.parentOffsetY(), uiScale); + resolved = new DraggableLayoutValue.State( + nx, + ny, + saved.anchorX(), + saved.anchorY(), + saved.parentId(), + saved.parentSide(), + npx, + npy, + true + ); + w.layoutValue().set(resolved); + w.saveConfig(); + } + } + float safeW = w.getWidth() > 0f ? w.getWidth() : 20f; + float safeH = w.getHeight() > 0f ? w.getHeight() : 20f; + float clampedX = saved.anchorX() == HudAnchorX.FREE + ? Math.max(0, Math.min(resolved.x(), screenW - safeW)) + : resolved.x(); + float clampedY = saved.anchorY() == HudAnchorY.FREE + ? Math.max(0, Math.min(resolved.y(), screenH - safeH)) + : resolved.y(); + if (saved.anchorX() == HudAnchorX.FREE && saved.anchorY() == HudAnchorY.FREE + && (clampedX != resolved.x() || clampedY != resolved.y())) { + DebugLog.config("[HUD] Saved position clamped for %s: (%.1f, %.1f) -> (%.1f, %.1f)", + w.getId(), resolved.x(), resolved.y(), clampedX, clampedY); + } else { + DebugLog.config("[HUD] Loaded saved position for %s: (%.1f, %.1f)", w.getId(), resolved.x(), resolved.y()); + } + w.moveTo(clampedX, clampedY); + w.setAnchors(resolved.anchorX(), resolved.anchorY(), clampedX, clampedY); + if (resolved.parentId() != null && resolved.parentSide() != WidgetAnchorSide.NONE) { + w.setParentAnchor(resolved.parentId(), resolved.parentSide(), resolved.parentOffsetX(), resolved.parentOffsetY()); + } else { + w.clearParentAnchor(); + } + } else { + w.applyDefaultPosition(screenW, screenH); + w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY()); + w.clearParentAnchor(); + DebugLog.config("[HUD] Applied default position for %s: (%.1f, %.1f)", w.getId(), w.getX(), w.getY()); + } + w.markPositionInitialized(); + } + + private static void handleDragging(Minecraft mc, int screenW, int screenH, float uiScale) { + boolean chatOpen = ClientScreen.current() instanceof ChatScreen; + boolean dragEditorOpen = forceVisible && ClientScreen.current() != null; + boolean allowDragging = chatOpen || dragEditorOpen; + float fbX = (float) mc.mouseHandler.xpos(); + float fbY = (float) mc.mouseHandler.ypos(); + float mx = HudScale.toVirtual(fbX, uiScale); + float my = HudScale.toVirtual(fbY, uiScale); + + boolean left = GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_1) == GLFW.GLFW_PRESS; + boolean middle = GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_MIDDLE) == GLFW.GLFW_PRESS; + + if (!allowDragging) { + if (dragging != null) { + //DebugLog.config("[HUD] Drag cancelled outside chat for %s", dragging.getId()); + } + if (left && !prevLeft) { + // DebugLog.config("[HUD] Mouse down ignored (chat not open) at (%.1f, %.1f)", mx, my); + } + if (!left && prevLeft) { + // DebugLog.config("[HUD] Mouse up ignored (chat not open) at (%.1f, %.1f)", mx, my); + } + dragging = null; + draggingWithLeft = false; + clearSnapPreview(); + if (hudCursorActive) { + SystemCursor.reset(); + hudCursorActive = false; + } + prevLeft = left; + prevMiddle = middle; + return; + } + + if (dragging != null && !isWidgetVisibleForEditor(dragging)) { + dragging = null; + draggingWithLeft = false; + clearSnapPreview(); + } + + if (left && !prevLeft) { + //DebugLog.config("[HUD] Mouse down in chat at (%.1f, %.1f)", mx, my); + for (DraggableHudElement w : WIDGETS) { + /*DebugLog.config("[HUD] Widget %s bounds x=%.1f y=%.1f w=%.1f h=%.1f contains=%s", + w.getId(), w.getX(), w.getY(), w.getWidth(), w.getHeight(), w.contains(mx, my));*/ + } + DraggableHudElement clickTarget = null; + long bestClickOrder = Long.MIN_VALUE; + for (DraggableHudElement w : WIDGETS) { + if (!isWidgetVisibleForEditor(w)) continue; + if (!w.contains(mx, my)) continue; + if (!w.isMouseOverInteractive(mx, my)) continue; + long order = w.getRuntimeRenderOrder(); + if (order > bestClickOrder) { + bestClickOrder = order; + clickTarget = w; + } + } + if (clickTarget != null) { + if (clickTarget.onMouseClicked(mx, my, GLFW.GLFW_MOUSE_BUTTON_1)) { + dragging = null; + draggingWithLeft = false; + clearSnapPreview(); + prevLeft = left; + prevMiddle = middle; + return; + } + // interactive hitbox: do not start drag on left click + prevLeft = left; + prevMiddle = middle; + return; + } else { + // start drag with left click if no interactive hitbox + DraggableHudElement candidate = null; + long bestOrder = Long.MIN_VALUE; + for (DraggableHudElement w : WIDGETS) { + if (!isWidgetVisibleForEditor(w)) continue; + if (!w.isDraggable() || !w.contains(mx, my)) continue; + long order = w.getRuntimeRenderOrder(); + if (order > bestOrder) { + bestOrder = order; + candidate = w; + } + } + if (candidate == null) { + for (int i = WIDGETS.size() - 1; i >= 0; i--) { + DraggableHudElement w = WIDGETS.get(i); + if (!isWidgetVisibleForEditor(w)) continue; + if (w.contains(mx, my) && w.isDraggable()) { + candidate = w; + break; + } + } + } + if (candidate != null) { + dragging = candidate; + draggingWithLeft = true; + dragOffsetX = mx - candidate.getX(); + dragOffsetY = my - candidate.getY(); + if (isWidgetAnchorModifierDown()) { + detachChildren(candidate); + } + dragging.clearParentAnchor(); + dragging.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, dragging.getX(), dragging.getY()); + SystemCursor.set(SystemCursor.CursorType.MOVE); + hudCursorActive = true; + } + } + } + + if (middle && !prevMiddle) { + // start drag + DraggableHudElement candidate = null; + long bestOrder = Long.MIN_VALUE; + for (DraggableHudElement w : WIDGETS) { + if (!isWidgetVisibleForEditor(w)) continue; + if (!w.isDraggable() || !w.contains(mx, my)) continue; + long order = w.getRuntimeRenderOrder(); + if (order > bestOrder) { + bestOrder = order; + candidate = w; + } + } + if (candidate == null) { + for (int i = WIDGETS.size() - 1; i >= 0; i--) { + DraggableHudElement w = WIDGETS.get(i); + if (!isWidgetVisibleForEditor(w)) continue; + if (w.contains(mx, my) && w.isDraggable()) { + candidate = w; + break; + } + } + } + if (candidate != null) { + dragging = candidate; + draggingWithLeft = false; + dragOffsetX = mx - candidate.getX(); + dragOffsetY = my - candidate.getY(); + if (isWidgetAnchorModifierDown()) { + detachChildren(candidate); + } + dragging.clearParentAnchor(); + dragging.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, dragging.getX(), dragging.getY()); + //DebugLog.config("[HUD] Drag start for %s at (%.1f, %.1f)", dragging.getId(), dragging.getX(), dragging.getY()); + SystemCursor.set(SystemCursor.CursorType.MOVE); + hudCursorActive = true; + } + if (dragging == null) { + //DebugLog.config("[HUD] No widget found under cursor at (%.1f, %.1f); widgets=%d", mx, my, WIDGETS.size()); + } + } else if (!middle && prevMiddle && dragging != null && !draggingWithLeft) { + stopDragging(screenW, screenH); + } else if (!middle && prevMiddle) { + //DebugLog.config("[HUD] Mouse up with no active drag at (%.1f, %.1f)", mx, my); + } + + if (draggingWithLeft && !left && prevLeft && dragging != null) { + stopDragging(screenW, screenH); + } + + if (((draggingWithLeft && left) || (!draggingWithLeft && middle)) && dragging != null) { + moveDraggedWidget(dragging, mx, my, screenW, screenH); + if (!isWidgetAnchorModifierDown()) { + updateSnapPreview(dragging, screenW, screenH); + } else { + clearSnapPreview(); + } + //DebugLog.config("[HUD] Dragging %s -> (%.1f, %.1f)", dragging.getId(), dragging.getX(), dragging.getY()); + SystemCursor.set(SystemCursor.CursorType.MOVE); + hudCursorActive = true; + } else if (dragging == null) { + clearSnapPreview(); + requestHoverCursor(mx, my); + } + + prevLeft = left; + prevMiddle = middle; + } + + private static void stopDragging(int screenW, int screenH) { + if (dragging == null) return; + if (!isWidgetAnchorModifierDown()) { + applyStickySnap(dragging, screenW, screenH); + } + dragging.clearParentAnchor(); + Minecraft mc = Minecraft.getInstance(); + boolean allowWidgetAnchoring = mc == null || !isWidgetAnchorModifierDown(); + updateDragAnchors(dragging, screenW, screenH, allowWidgetAnchoring); + dragging.layoutValue().set(dragging.snapshotLayout()); + dragging.saveConfig(); + dragging = null; + draggingWithLeft = false; + clearSnapPreview(); + if (hudCursorActive) { + SystemCursor.reset(); + hudCursorActive = false; + } + } + + private static void cancelDragging() { + dragging = null; + draggingWithLeft = false; + clearSnapPreview(); + if (hudCursorActive) { + SystemCursor.reset(); + hudCursorActive = false; + } + prevLeft = false; + prevMiddle = false; + } + + private static void requestHoverCursor(float mx, float my) { + DraggableHudElement interactive = findTopVisibleWidget(mx, my, true); + if (interactive != null) { + SystemCursor.set(SystemCursor.CursorType.HAND); + hudCursorActive = true; + return; + } + + DraggableHudElement draggable = findTopVisibleWidget(mx, my, false); + if (draggable != null) { + SystemCursor.set(SystemCursor.CursorType.MOVE); + hudCursorActive = true; + } + } + + private static DraggableHudElement findTopVisibleWidget(float mx, float my, boolean interactiveOnly) { + DraggableHudElement best = null; + long bestOrder = Long.MIN_VALUE; + for (DraggableHudElement w : WIDGETS) { + if (!isWidgetVisibleForEditor(w)) continue; + if (!w.contains(mx, my)) continue; + + if (interactiveOnly) { + if (!w.isMouseOverInteractive(mx, my)) continue; + } else if (!w.isDraggable()) { + continue; + } + + long order = w.getRuntimeRenderOrder(); + if (order > bestOrder) { + bestOrder = order; + best = w; + } + } + + if (best != null) return best; + for (int i = WIDGETS.size() - 1; i >= 0; i--) { + DraggableHudElement w = WIDGETS.get(i); + if (!isWidgetVisibleForEditor(w)) continue; + if (!w.contains(mx, my)) continue; + + if (interactiveOnly) { + if (!w.isMouseOverInteractive(mx, my)) continue; + } else if (!w.isDraggable()) { + continue; + } + + return w; + } + + return null; + } + + private static boolean isWidgetVisibleForEditor(DraggableHudElement w) { + if (w == null) return false; + + boolean singleEditorPreview = editorWidgetId != null; + if (singleEditorPreview && (editorWidgetId.isEmpty() || !editorWidgetId.equals(w.getId()))) { + return false; + } + + boolean bypassEnabled = singleEditorPreview && forceVisible; + if (!bypassEnabled && !w.isEnabled()) { + return false; + } + + if (!w.usesEngineRenderer()) return false; + if (w.getRenderSpace() != HudRenderSpace.UNSCALED_LOGICAL) return false; + return w.getRuntimeRenderOrder() > 0L; + } + + private static boolean isRenderableCandidate(DraggableHudElement w, HudPhase phase) { + if (w == null) return false; + + boolean singleEditorPreview = editorWidgetId != null; + if (singleEditorPreview && (editorWidgetId.isEmpty() || !editorWidgetId.equals(w.getId()))) { + return false; + } + + boolean bypassEnabled = singleEditorPreview && forceVisible; + if (!bypassEnabled && !w.isEnabled()) { + return false; + } + + if (!w.usesEngineRenderer()) return false; + if (w.getHudPhase() != phase) return false; + return w.getRenderSpace() == HudRenderSpace.UNSCALED_LOGICAL; + } + + private static boolean isWidgetVisibleForLinkOverlay(DraggableHudElement w) { + if (w == null) return false; + + boolean singleEditorPreview = editorWidgetId != null; + if (singleEditorPreview && (editorWidgetId.isEmpty() || !editorWidgetId.equals(w.getId()))) { + return false; + } + + boolean bypassEnabled = singleEditorPreview && forceVisible; + if (!bypassEnabled && !w.isEnabled()) { + return false; + } + + return w.usesEngineRenderer() && w.getRenderSpace() == HudRenderSpace.UNSCALED_LOGICAL; + } + + private static void applyAnchors(DraggableHudElement w, int screenW, int screenH) { + applyAnchors(w, screenW, screenH, new HashSet<>()); + } + + private static void applyAnchors(DraggableHudElement w, int screenW, int screenH, Set visiting) { + if (w.getParentId() == null || w.getParentSide() == WidgetAnchorSide.NONE) { + applyScreenAnchor(w, screenW, screenH); + return; + } + DraggableHudElement parent = findById(w.getParentId()); + if (parent == null) { + w.clearParentAnchor(); + applyScreenAnchor(w, screenW, screenH); + return; + } + if (!visiting.add(w.getId())) { + w.clearParentAnchor(); + applyScreenAnchor(w, screenW, screenH); + return; + } + if (parent != dragging) { + applyAnchors(parent, screenW, screenH, visiting); + } + if (isDescendantOf(parent, w)) { + w.clearParentAnchor(); + applyScreenAnchor(w, screenW, screenH); + } else { + applyWidgetAnchor(w, parent, screenW, screenH); + } + visiting.remove(w.getId()); + } + + private static void applyScreenAnchor(DraggableHudElement w, int screenW, int screenH) { + float x = w.getAnchorOffsetX(); + float y = w.getAnchorOffsetY(); + switch (w.getAnchorX()) { + case LEFT -> x = w.getAnchorOffsetX(); + case CENTER -> x = screenW * 0.5f - w.getWidth() * 0.5f + w.getAnchorOffsetX(); + case RIGHT -> x = screenW - w.getWidth() - w.getAnchorOffsetX(); + case FREE -> x = w.getAnchorOffsetX(); + } + switch (w.getAnchorY()) { + case TOP -> y = w.getAnchorOffsetY(); + case CENTER -> y = screenH * 0.5f - w.getHeight() * 0.5f + w.getAnchorOffsetY(); + case BOTTOM -> y = screenH - w.getHeight() - w.getAnchorOffsetY(); + case FREE -> y = w.getAnchorOffsetY(); + } + w.moveTo(clampX(x, w, screenW), clampY(y, w, screenH)); + } + + private static void applyWidgetAnchor(DraggableHudElement w, DraggableHudElement parent, int screenW, int screenH) { + float x = w.getX(); + float y = w.getY(); + switch (w.getParentSide()) { + case TOP -> { + x = parent.getX() + w.getParentOffsetX(); + y = parent.getY() - w.getHeight() - PARENT_GAP + w.getParentOffsetY(); + } + case BOTTOM -> { + x = parent.getX() + w.getParentOffsetX(); + y = parent.getY() + parent.getHeight() + PARENT_GAP + w.getParentOffsetY(); + } + case LEFT -> { + x = parent.getX() - w.getWidth() - PARENT_GAP + w.getParentOffsetX(); + y = parent.getY() + w.getParentOffsetY(); + } + case RIGHT -> { + x = parent.getX() + parent.getWidth() + PARENT_GAP + w.getParentOffsetX(); + y = parent.getY() + w.getParentOffsetY(); + } + case NONE -> { + } + } + w.moveTo(clampX(x, w, screenW), clampY(y, w, screenH)); + } + + private static void updateDragAnchors(DraggableHudElement w, int screenW, int screenH, boolean allowWidgetAnchoring) { + if (w.getWidth() <= 0 || w.getHeight() <= 0) { + w.clearParentAnchor(); + w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY()); + return; + } + + if (!allowWidgetAnchoring) { + w.clearParentAnchor(); + w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY()); + return; + } + + if (w.supportsWidgetAnchoring()) { + WidgetAnchorCandidate candidate = findWidgetAnchorCandidate(w, screenW, screenH); + if (candidate != null) { + w.setParentAnchor(candidate.parent.getId(), candidate.side, candidate.offsetX, candidate.offsetY); + return; + } + } + w.clearParentAnchor(); + + float x = w.getX(); + float y = w.getY(); + + HudAnchorX ax = HudAnchorX.FREE; + float offX = x; + float leftDist = Math.abs(x); + float rightDist = Math.abs((x + w.getWidth()) - screenW); + float centerDistX = Math.abs((x + w.getWidth() * 0.5f) - screenW * 0.5f); + if (leftDist <= SNAP_DIST) { + ax = HudAnchorX.LEFT; + offX = x; + } else if (rightDist <= SNAP_DIST) { + ax = HudAnchorX.RIGHT; + offX = rightDist; + } else if (centerDistX <= SNAP_DIST) { + ax = HudAnchorX.CENTER; + offX = (x + w.getWidth() * 0.5f) - screenW * 0.5f; + } + + HudAnchorY ay = HudAnchorY.FREE; + float offY = y; + float topDist = Math.abs(y); + float bottomDist = Math.abs((y + w.getHeight()) - screenH); + float centerDistY = Math.abs((y + w.getHeight() * 0.5f) - screenH * 0.5f); + if (topDist <= SNAP_DIST) { + ay = HudAnchorY.TOP; + offY = y; + } else if (bottomDist <= SNAP_DIST) { + ay = HudAnchorY.BOTTOM; + offY = bottomDist; + } else if (centerDistY <= SNAP_DIST) { + ay = HudAnchorY.CENTER; + offY = (y + w.getHeight() * 0.5f) - screenH * 0.5f; + } + + w.setAnchors(ax, ay, offX, offY); + } + + private static void handleScreenResize(int screenW, int screenH) { + if (screenW <= 0 || screenH <= 0) return; + if (lastScreenW <= 0 || lastScreenH <= 0) { + lastScreenW = screenW; + lastScreenH = screenH; + return; + } + if (lastScreenW == screenW && lastScreenH == screenH) return; + + int oldW = lastScreenW; + int oldH = lastScreenH; + lastScreenW = screenW; + lastScreenH = screenH; + + for (DraggableHudElement w : WIDGETS) { + if (!w.isPositionInitialized()) continue; + if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) { + // Parent-anchored widgets will be handled when anchors are applied. + continue; + } + if (w.getWidth() <= 0 || w.getHeight() <= 0) continue; + + boolean changed = false; + if (w.getAnchorX() == HudAnchorX.FREE && w.getAnchorY() == HudAnchorY.FREE) { + changed |= inferScreenAnchors(w, oldW, oldH); + } + + applyScreenAnchor(w, screenW, screenH); + changed |= clampToScreenIfFree(w, screenW, screenH); + + if (changed) { + w.layoutValue().set(w.snapshotLayout()); + w.saveConfig(); + } + } + } + + private static boolean inferScreenAnchors(DraggableHudElement w, int screenW, int screenH) { + float x = w.getX(); + float y = w.getY(); + boolean changed = false; + + HudAnchorX ax = w.getAnchorX(); + float offX = w.getAnchorOffsetX(); + float leftDist = Math.abs(x); + float rightDist = Math.abs((x + w.getWidth()) - screenW); + float centerDistX = Math.abs((x + w.getWidth() * 0.5f) - screenW * 0.5f); + if (leftDist <= SNAP_DIST) { + ax = HudAnchorX.LEFT; + offX = x; + } else if (rightDist <= SNAP_DIST) { + ax = HudAnchorX.RIGHT; + offX = rightDist; + } else if (centerDistX <= SNAP_DIST) { + ax = HudAnchorX.CENTER; + offX = (x + w.getWidth() * 0.5f) - screenW * 0.5f; + } + + HudAnchorY ay = w.getAnchorY(); + float offY = w.getAnchorOffsetY(); + float topDist = Math.abs(y); + float bottomDist = Math.abs((y + w.getHeight()) - screenH); + float centerDistY = Math.abs((y + w.getHeight() * 0.5f) - screenH * 0.5f); + if (topDist <= SNAP_DIST) { + ay = HudAnchorY.TOP; + offY = y; + } else if (bottomDist <= SNAP_DIST) { + ay = HudAnchorY.BOTTOM; + offY = bottomDist; + } else if (centerDistY <= SNAP_DIST) { + ay = HudAnchorY.CENTER; + offY = (y + w.getHeight() * 0.5f) - screenH * 0.5f; + } + + if (ax != w.getAnchorX() || ay != w.getAnchorY() + || offX != w.getAnchorOffsetX() || offY != w.getAnchorOffsetY()) { + w.setAnchors(ax, ay, offX, offY); + changed = true; + } + return changed; + } + + private static boolean clampToScreenIfFree(DraggableHudElement w, int screenW, int screenH) { + boolean changed = false; + float newX = w.getX(); + float newY = w.getY(); + if (w.getAnchorX() == HudAnchorX.FREE) { + float maxX = Math.max(0f, screenW - w.getWidth()); + newX = Math.max(0f, Math.min(newX, maxX)); + } + if (w.getAnchorY() == HudAnchorY.FREE) { + float maxY = Math.max(0f, screenH - w.getHeight()); + newY = Math.max(0f, Math.min(newY, maxY)); + } + if (newX != w.getX() || newY != w.getY()) { + w.moveTo(newX, newY); + if (w.getAnchorX() == HudAnchorX.FREE || w.getAnchorY() == HudAnchorY.FREE) { + w.setAnchors(w.getAnchorX(), w.getAnchorY(), + w.getAnchorX() == HudAnchorX.FREE ? newX : w.getAnchorOffsetX(), + w.getAnchorY() == HudAnchorY.FREE ? newY : w.getAnchorOffsetY()); + } + changed = true; + } + return changed; + } + + + private static boolean isWidgetAnchorModifierDown() { + return KeyManager.isComboHeldAllowScreen("LEFT_ALT") + || KeyManager.isComboHeldAllowScreen("RIGHT_ALT"); + } + + private static void detachChildren(DraggableHudElement parent) { + String parentId = parent.getId(); + for (DraggableHudElement w : WIDGETS) { + if (w == parent) continue; + if (parentId.equals(w.getParentId()) && w.getParentSide() != WidgetAnchorSide.NONE) { + w.clearParentAnchor(); + w.setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, w.getX(), w.getY()); + w.layoutValue().set(w.snapshotLayout()); + w.saveConfig(); + } + } + } + + private static WidgetAnchorCandidate findWidgetAnchorCandidate(DraggableHudElement dragging, int screenW, int screenH) { + float bestDist = Float.MAX_VALUE; + WidgetAnchorCandidate best = null; + for (DraggableHudElement target : WIDGETS) { + if (target == dragging) continue; + if (!isWidgetVisibleForLinkOverlay(target)) continue; + if (isDescendantOf(target, dragging)) continue; + if (target.getWidth() <= 0 || target.getHeight() <= 0) continue; + + float dxOverlap = overlap(dragging.getX(), dragging.getX() + dragging.getWidth(), + target.getX(), target.getX() + target.getWidth()); + float dyOverlap = overlap(dragging.getY(), dragging.getY() + dragging.getHeight(), + target.getY(), target.getY() + target.getHeight()); + + float distTop = Math.abs((dragging.getY() + dragging.getHeight()) - (target.getY() - PARENT_GAP)); + if (dxOverlap > 0 && distTop <= PARENT_SNAP_DIST && distTop < bestDist) { + float offsetX = parentAxisOffset(dragging.getX() - target.getX()); + float desiredY = target.getY() - PARENT_GAP - dragging.getHeight(); + float desiredX = target.getX() + offsetX; + if (!fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue; + float offsetY = 0f; + bestDist = distTop; + best = new WidgetAnchorCandidate(target, WidgetAnchorSide.TOP, offsetX, offsetY); + } + + float distBottom = Math.abs(dragging.getY() - (target.getY() + target.getHeight() + PARENT_GAP)); + if (dxOverlap > 0 && distBottom <= PARENT_SNAP_DIST && distBottom < bestDist) { + float offsetX = parentAxisOffset(dragging.getX() - target.getX()); + float desiredY = target.getY() + target.getHeight() + PARENT_GAP; + float desiredX = target.getX() + offsetX; + if (!fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue; + float offsetY = 0f; + bestDist = distBottom; + best = new WidgetAnchorCandidate(target, WidgetAnchorSide.BOTTOM, offsetX, offsetY); + } + + float distLeft = Math.abs((dragging.getX() + dragging.getWidth()) - (target.getX() - PARENT_GAP)); + if (dyOverlap > 0 && distLeft <= PARENT_SNAP_DIST && distLeft < bestDist) { + float offsetY = parentAxisOffset(dragging.getY() - target.getY()); + float desiredX = target.getX() - PARENT_GAP - dragging.getWidth(); + float desiredY = target.getY() + offsetY; + if (!fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue; + float offsetX = 0f; + bestDist = distLeft; + best = new WidgetAnchorCandidate(target, WidgetAnchorSide.LEFT, offsetX, offsetY); + } + + float distRight = Math.abs(dragging.getX() - (target.getX() + target.getWidth() + PARENT_GAP)); + if (dyOverlap > 0 && distRight <= PARENT_SNAP_DIST && distRight < bestDist) { + float offsetY = parentAxisOffset(dragging.getY() - target.getY()); + float desiredX = target.getX() + target.getWidth() + PARENT_GAP; + float desiredY = target.getY() + offsetY; + if (!fitsLinkedBoundsAt(dragging, desiredX, desiredY, screenW, screenH)) continue; + float offsetX = 0f; + bestDist = distRight; + best = new WidgetAnchorCandidate(target, WidgetAnchorSide.RIGHT, offsetX, offsetY); + } + } + return best; + } + + private static float parentAxisOffset(float offset) { + return Math.abs(offset) <= PARENT_ALIGN_SNAP_DIST ? 0f : offset; + } + + private static float overlap(float a0, float a1, float b0, float b1) { + float left = Math.max(a0, b0); + float right = Math.min(a1, b1); + return right - left; + } + + private static DraggableHudElement findById(String id) { + if (id == null) return null; + for (DraggableHudElement w : WIDGETS) { + if (id.equals(w.getId())) return w; + } + return null; + } + + private static void moveDraggedWidget(DraggableHudElement w, float mx, float my, int screenW, int screenH) { + float newX = mx - dragOffsetX; + float newY = my - dragOffsetY; + Bounds groupBounds = collectLinkedBounds(w, screenW, screenH); + float dx = newX - w.getX(); + float dy = newY - w.getY(); + + if (groupBounds != null && groupBounds.width() <= screenW && groupBounds.height() <= screenH) { + dx = clamp(dx, -groupBounds.minX(), screenW - groupBounds.maxX()); + dy = clamp(dy, -groupBounds.minY(), screenH - groupBounds.maxY()); + w.moveTo(w.getX() + dx, w.getY() + dy); + applyDescendantAnchors(w, screenW, screenH, new HashSet<>()); + return; + } + + w.moveTo(clampX(newX, w, screenW), clampY(newY, w, screenH)); + applyDescendantAnchors(w, screenW, screenH, new HashSet<>()); + } + + private static void updateSnapPreview(DraggableHudElement w, int screenW, int screenH) { + if (w == null || w.getWidth() <= 0 || w.getHeight() <= 0) { + clearSnapPreview(); + return; + } + + StickySnap snap = findStickySnap(w, screenW, screenH); + if (!snap.hasX && !snap.hasY) { + clearSnapPreview(); + return; + } + + snapPreviewActive = true; + snapPreviewHasX = snap.hasX; + snapPreviewHasY = snap.hasY; + snapPreviewLineX = snap.x; + snapPreviewLineY = snap.y; + } + + private static void applyStickySnap(DraggableHudElement w, int screenW, int screenH) { + if (w == null || w.getWidth() <= 0 || w.getHeight() <= 0) return; + + StickySnap snap = findStickySnap(w, screenW, screenH); + float newX = w.getX(); + float newY = w.getY(); + if (snap.hasX) newX = snap.x; + if (snap.hasY) newY = snap.y; + w.moveTo(clampX(newX, w, screenW), clampY(newY, w, screenH)); + } + + private static StickySnap findStickySnap(DraggableHudElement dragging, int screenW, int screenH) { + float bestX = 0f; + float bestY = 0f; + float bestXDist = Float.MAX_VALUE; + float bestYDist = Float.MAX_VALUE; + boolean hasX = false; + boolean hasY = false; + + boolean rightSide = dragging.getX() > screenW * 0.5f; + for (DraggableHudElement target : WIDGETS) { + if (target == dragging) continue; + if (!target.isEnabled()) continue; + if (target.getWidth() <= 0 || target.getHeight() <= 0) continue; + + if (rightSide) { + float targetEdge = target.getX() + target.getWidth(); + float dragEdge = dragging.getX() + dragging.getWidth(); + float dist = Math.abs(targetEdge - dragEdge); + if (dist <= PARENT_SNAP_DIST && dist < bestXDist) { + bestXDist = dist; + bestX = targetEdge - dragging.getWidth(); + hasX = true; + } + } else { + float dist = Math.abs(target.getX() - dragging.getX()); + if (dist <= PARENT_SNAP_DIST && dist < bestXDist) { + bestXDist = dist; + bestX = target.getX(); + hasX = true; + } + } + + float distY = Math.abs(target.getY() - dragging.getY()); + if (distY <= PARENT_SNAP_DIST && distY < bestYDist) { + bestYDist = distY; + bestY = target.getY(); + hasY = true; + } + } + + float distTop = Math.abs(dragging.getY()); + if (distTop <= PARENT_SNAP_DIST && distTop < bestYDist) { + bestY = 0f; + hasY = true; + } + + return new StickySnap(hasX, hasY, bestX, bestY); + } + + private static void clearSnapPreview() { + snapPreviewActive = false; + snapPreviewHasX = false; + snapPreviewHasY = false; + } + + private static void renderDragOverlay(Renderer2D renderer, + TextRenderer fallbackTextRenderer, + int screenW, + int screenH) { + renderLinkWires(renderer, screenW, screenH); + + if (snapPreviewActive) { + if (snapPreviewHasX) { + renderer.line(snapPreviewLineX, 0f, snapPreviewLineX, screenH, SNAP_PREVIEW_LINE); + } + if (snapPreviewHasY) { + renderer.line(0f, snapPreviewLineY, screenW, snapPreviewLineY, SNAP_PREVIEW_LINE); + } + } + + if (dragging == null) return; + TextRenderer coordRenderer = Fonts.renderer("Comfortaa", FontInfo.Type.Regular, + fallbackTextRenderer != null ? fallbackTextRenderer : TextRenderer.get()); + if (coordRenderer == null) return; + + String coords = "x: " + Math.round(dragging.getX()) + " y: " + Math.round(dragging.getY()); + coordRenderer.begin(DRAG_COORD_TEXT_SCALE, false, false); + try { + float textW = (float) coordRenderer.getWidth(coords, true); + float textH = (float) coordRenderer.getHeight(true); + float textX = dragging.getX(); + float textY = dragging.getY() - textH - DRAG_COORD_MARGIN; + if (textY < DRAG_COORD_MARGIN) { + textY = dragging.getY() + dragging.getHeight() + DRAG_COORD_MARGIN; + } + textX = clamp(textX, DRAG_COORD_MARGIN, Math.max(DRAG_COORD_MARGIN, screenW - textW - DRAG_COORD_MARGIN)); + textY = clamp(textY, DRAG_COORD_MARGIN, Math.max(DRAG_COORD_MARGIN, screenH - textH - DRAG_COORD_MARGIN)); + coordRenderer.render(coords, textX, textY, new RenderColor(0xFFFFFFFF), true); + } finally { + coordRenderer.end(); + } + } + + private static void renderLinkWires(Renderer2D renderer, + int screenW, + int screenH) { + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null || !(ClientScreen.current() instanceof ChatScreen)) + return; + + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + float uiScale = HudScale.scale(fbw, fbh); + float mx = HudScale.toVirtual((float) mc.mouseHandler.xpos(), uiScale); + float my = HudScale.toVirtual((float) mc.mouseHandler.ypos(), uiScale); + + LinkWireColors colors = linkWireColors(); + for (DraggableHudElement child : WIDGETS) { + if (!isWidgetVisibleForLinkOverlay(child)) continue; + if (child == dragging) continue; + if (child.getParentId() == null || child.getParentSide() == WidgetAnchorSide.NONE) continue; + + DraggableHudElement parent = findById(child.getParentId()); + if (!isWidgetVisibleForLinkOverlay(parent)) continue; + if (!parent.contains(mx, my) && !child.contains(mx, my)) continue; + renderLinkWire(renderer, parent, child, child.getParentSide(), + LINK_WIRE_THICKNESS, colors.wireStart(), colors.wireEnd()); + } + + if (dragging == null || isWidgetAnchorModifierDown()) return; + WidgetAnchorCandidate candidate = findWidgetAnchorCandidate(dragging, screenW, screenH); + if (candidate == null) return; + renderLinkWire(renderer, candidate.parent(), dragging, candidate.side(), + LINK_PREVIEW_THICKNESS, colors.previewStart(), colors.previewEnd()); + } + + private static LinkWireColors linkWireColors() { + Themes.Theme theme = Theme.theme(); + if (cachedLinkTheme == theme && cachedLinkColors != null) { + return cachedLinkColors; + } + + int wireStart = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme.textMuted(), theme.strokeSoft(), 0.45f), 0x86); + int wireEnd = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme.accentSoft(), theme.windowStroke(), 0.24f), 0x78); + int previewStart = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme.textPrimary(), theme.accentSoft(), 0.38f), 0xC8); + int previewEnd = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme.accent(), theme.accentSoft(), 0.28f), 0xD8); + + cachedLinkTheme = theme; + cachedLinkColors = new LinkWireColors(wireStart, wireEnd, previewStart, previewEnd); + return cachedLinkColors; + } + + private static void renderLinkWire(Renderer2D renderer, + DraggableHudElement parent, + DraggableHudElement child, + WidgetAnchorSide side, + double thickness, + int startArgb, + int endArgb) { + if (parent == null || child == null || side == null || side == WidgetAnchorSide.NONE) return; + if (parent.getWidth() <= 0f || parent.getHeight() <= 0f || child.getWidth() <= 0f || child.getHeight() <= 0f) { + return; + } + + double parentX = 0.0; + double parentY = 0.0; + double childX = 0.0; + double childY = 0.0; + double parentCx = 0.0; + double parentCy = 0.0; + double childCx = 0.0; + double childCy = 0.0; + + switch (side) { + case TOP -> { + parentX = parent.getX() + parent.getWidth() * 0.5; + parentY = parent.getY(); + childX = child.getX() + child.getWidth() * 0.5; + childY = child.getY() + child.getHeight(); + double c = Math.max(14.0, Math.abs(childY - parentY) * 0.45); + parentCx = parentX; + parentCy = parentY - c; + childCx = childX; + childCy = childY + c; + } + case BOTTOM -> { + parentX = parent.getX() + parent.getWidth() * 0.5; + parentY = parent.getY() + parent.getHeight(); + childX = child.getX() + child.getWidth() * 0.5; + childY = child.getY(); + double c = Math.max(14.0, Math.abs(childY - parentY) * 0.45); + parentCx = parentX; + parentCy = parentY + c; + childCx = childX; + childCy = childY - c; + } + case LEFT -> { + parentX = parent.getX(); + parentY = parent.getY() + parent.getHeight() * 0.5; + childX = child.getX() + child.getWidth(); + childY = child.getY() + child.getHeight() * 0.5; + double c = Math.max(14.0, Math.abs(childX - parentX) * 0.45); + parentCx = parentX - c; + parentCy = parentY; + childCx = childX + c; + childCy = childY; + } + case RIGHT -> { + parentX = parent.getX() + parent.getWidth(); + parentY = parent.getY() + parent.getHeight() * 0.5; + childX = child.getX(); + childY = child.getY() + child.getHeight() * 0.5; + double c = Math.max(14.0, Math.abs(childX - parentX) * 0.45); + parentCx = parentX + c; + parentCy = parentY; + childCx = childX - c; + childCy = childY; + } + case NONE -> { + return; + } + } + + renderer.bezierConnectorGradient(childX, childY, childCx, childCy, parentCx, parentCy, parentX, parentY, + 18, thickness, startArgb, endArgb); + } + + private static void applyDragAnchorLock(DraggableHudElement w, int screenW, int screenH) { + if (w.getParentId() != null && w.getParentSide() != WidgetAnchorSide.NONE) { + DraggableHudElement parent = findById(w.getParentId()); + if (parent != null) { + applyWidgetAnchor(w, parent, screenW, screenH); + } + return; + } + if (w.getAnchorX() != HudAnchorX.FREE || w.getAnchorY() != HudAnchorY.FREE) { + applyScreenAnchor(w, screenW, screenH); + } + } + + private static float clampX(float x, DraggableHudElement w, int screenW) { + float maxX = Math.max(0f, screenW - Math.max(0f, w.getWidth())); + return clamp(x, 0f, maxX); + } + + private static float clampY(float y, DraggableHudElement w, int screenH) { + float maxY = Math.max(0f, screenH - Math.max(0f, w.getHeight())); + return clamp(y, 0f, maxY); + } + + private static float clamp(float value, float min, float max) { + if (max < min) return min; + return Math.max(min, Math.min(value, max)); + } + + private static void applyDescendantAnchors(DraggableHudElement parent, int screenW, int screenH, Set visited) { + if (parent == null || !visited.add(parent.getId())) return; + String parentId = parent.getId(); + for (DraggableHudElement child : WIDGETS) { + if (child == parent) continue; + if (!parentId.equals(child.getParentId()) || child.getParentSide() == WidgetAnchorSide.NONE) continue; + applyWidgetAnchor(child, parent, screenW, screenH); + applyDescendantAnchors(child, screenW, screenH, visited); + } + } + + private static Bounds collectLinkedBounds(DraggableHudElement root, int screenW, int screenH) { + if (root == null) return null; + return collectLinkedBounds(root, screenW, screenH, new HashSet<>()); + } + + private static Bounds collectLinkedBounds(DraggableHudElement root, int screenW, int screenH, Set visited) { + if (root == null || !visited.add(root.getId())) return null; + Bounds bounds = Bounds.of(root); + String parentId = root.getId(); + for (DraggableHudElement child : WIDGETS) { + if (child == root) continue; + if (!parentId.equals(child.getParentId()) || child.getParentSide() == WidgetAnchorSide.NONE) continue; + applyWidgetAnchor(child, root, screenW, screenH); + bounds = Bounds.union(bounds, collectLinkedBounds(child, screenW, screenH, visited)); + } + return bounds; + } + + private static boolean fitsLinkedBoundsAt(DraggableHudElement root, + float desiredRootX, + float desiredRootY, + int screenW, + int screenH) { + Bounds bounds = collectLinkedBounds(root, screenW, screenH); + if (bounds == null) return false; + float dx = desiredRootX - root.getX(); + float dy = desiredRootY - root.getY(); + return bounds.minX() + dx >= 0f + && bounds.minY() + dy >= 0f + && bounds.maxX() + dx <= screenW + && bounds.maxY() + dy <= screenH; + } + + private static boolean isDescendantOf(DraggableHudElement possibleDescendant, DraggableHudElement ancestor) { + if (possibleDescendant == null || ancestor == null) return false; + String ancestorId = ancestor.getId(); + Set visited = new HashSet<>(); + DraggableHudElement current = possibleDescendant; + while (current != null && current.getParentId() != null && current.getParentSide() != WidgetAnchorSide.NONE) { + if (!visited.add(current.getId())) return false; + if (ancestorId.equals(current.getParentId())) return true; + current = findById(current.getParentId()); + } + return false; + } + + private static void noteRendered(DraggableHudElement w) { + if (w == null) return; + w.markRendered(++renderOrderSeq); + } + + private record StickySnap(boolean hasX, boolean hasY, float x, float y) { + } + + private record WidgetAnchorCandidate(DraggableHudElement parent, WidgetAnchorSide side, float offsetX, + float offsetY) { + } + + private record LinkWireColors(int wireStart, int wireEnd, int previewStart, int previewEnd) { + } + + private record Bounds(float minX, float minY, float maxX, float maxY) { + static Bounds of(DraggableHudElement w) { + return new Bounds(w.getX(), w.getY(), w.getX() + w.getWidth(), w.getY() + w.getHeight()); + } + + static Bounds union(Bounds a, Bounds b) { + if (a == null) return b; + if (b == null) return a; + return new Bounds( + Math.min(a.minX, b.minX), + Math.min(a.minY, b.minY), + Math.max(a.maxX, b.maxX), + Math.max(a.maxY, b.maxY) + ); + } + + float width() { + return maxX - minX; + } + + float height() { + return maxY - minY; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableLayoutValue.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableLayoutValue.java new file mode 100644 index 0000000..fecb32f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/DraggableLayoutValue.java @@ -0,0 +1,137 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable; + +import dev.loki.lovisual.config.values.ConfigValue; +import dev.loki.lovisual.features.gui.hud.HudAnchorX; +import dev.loki.lovisual.features.gui.hud.HudAnchorY; +import dev.loki.lovisual.features.gui.hud.WidgetAnchorSide; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Per-element draggable layout value stored inside element config. + */ +public final class DraggableLayoutValue extends ConfigValue { + + public DraggableLayoutValue(String name) { + super(name, null); + } + + @Override + public Object toJson() { + State v = value; + if (v == null) return null; + Map data = new LinkedHashMap<>(); + data.put("pos", List.of(v.x(), v.y())); + if (v.logical()) data.put("space", "logical"); + if (v.anchorX() != HudAnchorX.FREE) data.put("ax", v.anchorX().name()); + if (v.anchorY() != HudAnchorY.FREE) data.put("ay", v.anchorY().name()); + if (v.parentId() != null && !v.parentId().isEmpty() && v.parentSide() != WidgetAnchorSide.NONE) { + data.put("parent", v.parentId()); + data.put("side", v.parentSide().name()); + data.put("pox", v.parentOffsetX()); + data.put("poy", v.parentOffsetY()); + } + return data; + } + + @Override + public void fromJson(Object json) { + if (json == null) { + value = null; + return; + } + if (json instanceof List list && list.size() >= 2) { + Double x = toNumber(list.get(0)); + Double y = toNumber(list.get(1)); + if (x != null && y != null) { + value = State.simple(x.floatValue(), y.floatValue()); + } + return; + } + if (json instanceof Map data) { + float x = readFloat(data.get("x"), 0f); + float y = readFloat(data.get("y"), 0f); + Object pos = data.get("pos"); + if (pos instanceof List posList && posList.size() >= 2) { + Double px = toNumber(posList.get(0)); + Double py = toNumber(posList.get(1)); + if (px != null) x = px.floatValue(); + if (py != null) y = py.floatValue(); + } + HudAnchorX ax = parseAnchorX(readString(data.get("ax"))); + HudAnchorY ay = parseAnchorY(readString(data.get("ay"))); + String parent = readString(data.get("parent")); + WidgetAnchorSide side = parseAnchorSide(readString(data.get("side"))); + float pox = readFloat(data.get("pox"), 0f); + float poy = readFloat(data.get("poy"), 0f); + boolean logical = "logical".equalsIgnoreCase(readString(data.get("space"))); + value = new State(x, y, ax, ay, parent, side, pox, poy, logical); + } + } + + private Double toNumber(Object v) { + if (v instanceof Number n) return n.doubleValue(); + try { + return Double.parseDouble(String.valueOf(v)); + } catch (Exception ignore) { + return null; + } + } + + private float readFloat(Object v, float def) { + Double n = toNumber(v); + return n != null ? n.floatValue() : def; + } + + private String readString(Object v) { + if (v == null) return null; + String s = String.valueOf(v).trim(); + return s.isEmpty() ? null : s; + } + + private HudAnchorX parseAnchorX(String v) { + if (v == null) return HudAnchorX.FREE; + try { + return HudAnchorX.valueOf(v); + } catch (Exception ignore) { + return HudAnchorX.FREE; + } + } + + private HudAnchorY parseAnchorY(String v) { + if (v == null) return HudAnchorY.FREE; + try { + return HudAnchorY.valueOf(v); + } catch (Exception ignore) { + return HudAnchorY.FREE; + } + } + + private WidgetAnchorSide parseAnchorSide(String v) { + if (v == null) return WidgetAnchorSide.NONE; + try { + return WidgetAnchorSide.valueOf(v); + } catch (Exception ignore) { + return WidgetAnchorSide.NONE; + } + } + + public record State(float x, float y, + HudAnchorX anchorX, HudAnchorY anchorY, + String parentId, WidgetAnchorSide parentSide, + float parentOffsetX, float parentOffsetY, + boolean logical) { + public static State simple(float x, float y) { + return new State(x, y, HudAnchorX.FREE, HudAnchorY.FREE, null, WidgetAnchorSide.NONE, 0f, 0f, false); + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Admins.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Admins.java new file mode 100644 index 0000000..f9d441a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Admins.java @@ -0,0 +1,700 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.player.AbstractClientPlayer; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import net.minecraft.world.entity.player.Player; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.relations.StaffData; +import dev.loki.lovisual.features.relations.StaffTracker; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.PlayerHeadRenderer; +import dev.loki.lovisual.util.player.PlayerSkinResolver; +import dev.loki.lovisual.util.text.LegacyTextUtil; +import dev.loki.lovisual.util.text.TextRenderUtil; +import dev.loki.lovisual.util.text.TextRenderUtil.Part; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 50) +public final class Admins extends DraggableHudElement { + + { + defaultLinkedLayout(16.0f, 212.0f, "keybinds", "RIGHT", 0.0f, 0.0f); + } + + + private static final float HEADER_HEIGHT = 15.5f; + private static final float CONTENT_START_Y = 25.0f; + private static final float ROW_STEP = 11.0f; + private static final float MIN_WIDTH = 96.0f; + private static final float ROW_DIVIDER_X = 15.0f; + private static final float ROW_DIVIDER_H = 6.0f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float COUNT_LABEL_OFFSET = 22.0f; + private static final float ROW_HEAD_X = 3.5f; + private static final float ROW_HEAD_SIZE = 8.0f; + private static final float ROW_TEXT_X = 18.0f; + private static final float ROW_STATUS_RIGHT = 8.0f; + private static final float ROW_CONTENT_CENTER_OFFSET = 1.95f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedListHudPanel scriptedPanel = new ScriptedListHudPanel(); + private final NumberValue scaleValue = + new NumberValue<>("admins_scale", 1.0, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + private final ModeValue layoutMode = + new ModeValue("admins_layout", "Split Header", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + private final ModeValue colorMode = + new ModeValue("admins_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("admins_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final RGBAColorValue bg = + new RGBAColorValue("admins_bg", "#EB111318"); + private final RGBAColorValue bg2 = + new RGBAColorValue("admins_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("admins_bg_alpha", 131, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("admins_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("admins_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("admins_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("admins_blur_alpha", 255, 0, 255); + private final BooleanValue headerIconPulse = + new BooleanValue("admins_header_icon_pulse", true); + private final NumberValue headerIconPulseSpeed = + new NumberValue<>("admins_header_icon_pulse_speed", 18, 1, 60); + private final NumberValue headerIconPulseIntensity = + new NumberValue<>("admins_header_icon_pulse_intensity", 100, 0, 100); + + private final Map entryAnim = new LinkedHashMap<>(); + private final Map lastEntries = new LinkedHashMap<>(); + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiDivider; + private int uiBlurTint; + + public Admins() { + super("admins", "Admins", true); + } + + private static List filterStaff(List in) { + List out = new ArrayList<>(in.size()); + for (StaffData.StaffEntry entry : in) { + if (entry.status() == StaffTracker.Status.OFFLINE && entry.offlineSince() <= 0) continue; + if (entry.status() == StaffTracker.Status.WAITING && entry.lastGone() <= 0) continue; + out.add(entry); + } + return out; + } + + private static int clamp255(int value) { + return Math.max(0, Math.min(255, value)); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(headerIconPulse)); + defs.add(SettingDef.number(headerIconPulseSpeed).visibleWhen(headerIconPulse::get)); + defs.add(SettingDef.number(headerIconPulseIntensity).visibleWhen(headerIconPulse::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 200.0f; + this.y = 80.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !forceVisible)) { + width = 0.0f; + height = 0.0f; + return; + } + + boolean chatPreview = ClientScreen.current() instanceof ChatScreen; + List entries = filterStaff(StaffData.getEntries()); + List animatedEntries = animateEntries(entries); + boolean showExampleRow = entries.isEmpty() && (forceVisible || chatPreview); + boolean showWidget = !entries.isEmpty() || showExampleRow; + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + if (animatedEntries.isEmpty() && !showExampleRow && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + TextRenderer headerIconRenderer = Fonts.renderer("IconsNur", FontInfo.Type.Regular, TextRenderer.get()); + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer rowTextRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (rowTextRenderer == null) rowTextRenderer = textRenderer; + if (headerIconRenderer == null) headerIconRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(scaleValue); + float rowStep = ROW_STEP * baseScale; + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + + headerIconRenderer.begin(fontScale, true, false); + float headerIconH = (float) headerIconRenderer.getHeight(false); + headerIconRenderer.end(); + + headerTextRenderer.begin(fontScale, true, false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Admins", false); + headerTextRenderer.end(); + + long now = System.currentTimeMillis(); + rowTextRenderer.begin(fontScale, true, false); + float rowTextH = (float) rowTextRenderer.getHeight(false); + float maxWidth = MIN_WIDTH * baseScale; + if (showExampleRow) { + float previewNameWidth = (float) rowTextRenderer.getWidth("Example Staff", false); + float previewStatusWidth = (float) rowTextRenderer.getWidth("Vanish", false); + maxWidth = Math.max(maxWidth, previewNameWidth + previewStatusWidth + (30.0f * baseScale)); + } else { + for (AnimatedEntry animatedEntry : animatedEntries) { + StaffData.StaffEntry entry = animatedEntry.entry(); + float nameWidth = measureLegacyName(rowTextRenderer, entry.displayName()); + float statusWidth = (float) rowTextRenderer.getWidth(formatStatus(entry, now), false); + maxWidth = Math.max(maxWidth, nameWidth + statusWidth + (30.0f * baseScale)); + } + } + rowTextRenderer.end(); + + rowTextRenderer.begin(fontScale * 0.92f, false, false); + String activeCountText = Integer.toString(entries.size()); + float activeLabelWidth = (float) rowTextRenderer.getWidth("Active:", false); + float activeCountWidth = (float) rowTextRenderer.getWidth(activeCountText, false); + rowTextRenderer.end(); + + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + activeLabelWidth + activeCountWidth + + ((COUNT_LABEL_OFFSET + 12.0f) * baseScale); + maxWidth = Math.max(maxWidth, headerWidth); + + float contentHeight = showExampleRow ? rowStep : totalAnimatedHeight(animatedEntries, rowStep); + float targetHeight = (showExampleRow || !animatedEntries.isEmpty()) + ? (CONTENT_START_Y * baseScale + contentHeight) + : (HEADER_HEIGHT * baseScale); + + displayWidth = HudRenderUtil.animateDimension(displayWidth, maxWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + if (!showWidget && widgetScale <= 0.001f && animatedEntries.isEmpty()) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderIconH = headerIconH * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawRowTextH = rowTextH * drawScale; + int resolvedHeaderIconColor = uiCounter; + if (headerIconPulse.get()) { + int pulsedHeaderIconColor = HudTextEffects.animatedColor( + uiCounter, + HudTextEffects.Effect.PULSE, + headerIconPulseSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + resolvedHeaderIconColor = HudRenderUtil.mixColor( + uiCounter, + pulsedHeaderIconColor, + headerIconPulseIntensity.get() / 100.0f + ); + } + + List> panelRows = ScriptedListHudPanel.rows(); + rowTextRenderer.begin(drawFontScale, false, false); + if (showExampleRow) { + panelRows.add(ScriptedListHudPanel.row( + "preview", + "head", + ScriptedListHudPanel.idString(resolvePreviewSkin()), + List.of(ScriptedListHudPanel.textPart("Example Staff", withAlpha(uiText & 0x00FFFFFF, 255), 0.0f)), + "Vanish", + withAlpha(resolveStatusColor(StaffTracker.Status.VANISH) & 0x00FFFFFF, 255), + withAlpha(uiMuted & 0x00FFFFFF, 130), + 1.0f + )); + } else { + for (AnimatedEntry animatedEntry : animatedEntries) { + float anim = animatedEntry.anim(); + if (anim <= 0.01f) continue; + StaffData.StaffEntry entry = animatedEntry.entry(); + int rowAlpha = clamp255(Math.round(255.0f * anim)); + int nameBaseColor = withAlpha(uiText & 0x00FFFFFF, rowAlpha); + List> parts = legacyParts(rowTextRenderer, entry.displayName(), anim, nameBaseColor); + panelRows.add(ScriptedListHudPanel.row( + entry.name(), + "head", + ScriptedListHudPanel.idString(resolveSkinByName(entry.name())), + parts, + formatStatus(entry, now), + withAlpha(resolveStatusColor(entry.status()) & 0x00FFFFFF, rowAlpha), + withAlpha(uiMuted & 0x00FFFFFF, Math.max(26, rowAlpha - 120)), + anim + )); + } + } + rowTextRenderer.end(); + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedListHudPanel.Panel( + ScriptedListHudPanel.ADMINS, + new ScriptedListHudPanel.Palette(uiHeaderLeft, uiHeaderRight, uiBodyLeft, uiBodyRight, + uiOutline, uiText, uiMuted, uiCounter, uiTitleText, uiDivider, uiBlurTint), + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderIconH, + drawHeaderTextH, + drawRowTextH, + activeLabelWidth * drawScale, + activeCountWidth * drawScale, + entries.size(), + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + resolvedHeaderIconColor, + HudPanelLayoutModes.current(layoutMode), + panelRows + ) + ); + } + + @Override + public boolean isDraggable() { + return super.isDraggable(); + } + + private void renderExampleRow(Renderer2D renderer, + TextRenderer rowTextRenderer, + float fontScale, + float rowTextH, + float baseScale, + float renderWidth, + float centerY, + GuiGraphicsExtractor ctx, + float renderX) { + int textColor = withAlpha(uiText & 0x00FFFFFF, 255); + int statusColor = withAlpha(resolveStatusColor(StaffTracker.Status.VANISH) & 0x00FFFFFF, 255); + int dividerColor = withAlpha(uiMuted & 0x00FFFFFF, 130); + float rowContentCenterY = centerY + (ROW_CONTENT_CENTER_OFFSET * baseScale); + + renderHead( + ctx, + resolvePreviewSkin(), + renderX + (ROW_HEAD_X * baseScale), + rowContentCenterY, + ROW_HEAD_SIZE * baseScale, + 255, + baseScale + ); + + renderer.quad( + renderX + (ROW_DIVIDER_X * baseScale), + rowContentCenterY - ((ROW_DIVIDER_H * baseScale) * 0.5f), + Math.max(0.5f, 0.5f * baseScale), + ROW_DIVIDER_H * baseScale, + dividerColor + ); + + rowTextRenderer.begin(fontScale, false, false); + float textY = rowContentCenterY - (rowTextH * 0.5f); + rowTextRenderer.render("Example Staff", + renderX + (ROW_TEXT_X * baseScale), textY, new RenderColor(textColor), false); + float statusWidth = (float) rowTextRenderer.getWidth("Vanish", false); + rowTextRenderer.render("Vanish", + renderX + renderWidth - statusWidth - (ROW_STATUS_RIGHT * baseScale), + textY, + new RenderColor(statusColor), + false + ); + rowTextRenderer.end(); + } + + private void renderHead(GuiGraphicsExtractor ctx, + Identifier skin, + float headX, + float centerY, + float size, + int alpha, + float baseScale) { + float headY = centerY - (size * 0.5f); + float radius = Math.min(1.15f * baseScale, size * 0.24f); + if (skin != null && ctx != null) { + RenderColor color = new RenderColor(0xFFFFFFFF); + color.setAlpha(alpha); + PlayerHeadRenderer.drawRounded( + ctx, + headX, + headY, + size, + radius, + skin, + color, + true, + null, + 0.0f, + false + ); + return; + } + + int fill = withAlpha(uiText & 0x00FFFFFF, Math.max(28, alpha / 5)); + int strokeColor = withAlpha(uiMuted & 0x00FFFFFF, Math.max(80, alpha)); + Renderer2D.COLOR.roundedRect(headX, headY, size, size, radius, 1.0f, fill); + Renderer2D.COLOR.roundedRectStroke( + headX, headY, size, size, radius, 1.0f, + Math.max(0.45f, 0.55f * baseScale), + strokeColor + ); + } + + private Identifier resolvePreviewSkin() { + if (mc != null && mc.player != null) { + return PlayerSkinResolver.resolveProfileSkin(mc.player.getGameProfile()); + } + return null; + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + return; + } + + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + private List animateEntries(List entries) { + Map current = new LinkedHashMap<>(); + for (StaffData.StaffEntry entry : entries) { + current.put(entry.name(), entry); + } + + Map merged = new LinkedHashMap<>(); + merged.putAll(current); + for (Map.Entry entry : lastEntries.entrySet()) { + merged.putIfAbsent(entry.getKey(), entry.getValue()); + } + lastEntries.clear(); + lastEntries.putAll(merged); + + List toRemove = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + String key = entry.getKey(); + float target = current.containsKey(key) ? 1.0f : 0.0f; + float anim = entryAnim.getOrDefault(key, 0.0f); + anim = AnimationUtility.approach(anim, target, AnimationUtility.deltaTime(), 12.0f); + anim = AnimationUtility.snap(anim, target, 0.02f); + if (anim <= 0.0f && target <= 0.0f) { + toRemove.add(key); + } else { + entryAnim.put(key, anim); + } + } + for (String key : toRemove) { + entryAnim.remove(key); + lastEntries.remove(key); + } + + List out = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + float anim = entryAnim.getOrDefault(entry.getKey(), 0.0f); + if (anim > 0.01f) { + out.add(new AnimatedEntry(entry.getValue(), anim)); + } + } + return out; + } + + private float totalAnimatedHeight(List rows, float rowStep) { + float out = 0.0f; + for (AnimatedEntry row : rows) { + out += rowStep * row.anim(); + } + return out; + } + + private float measureLegacyName(TextRenderer renderer, Component displayName) { + Component converted = LegacyTextUtil.convertLegacyCodes(displayName); + List parts = TextRenderUtil.flattenStyled(converted, theme().textPrimary()); + float out = 0.0f; + for (Part part : parts) { + out += (float) renderer.getWidth(part.text(), false); + } + return out; + } + + private void drawLegacyName(TextRenderer renderer, + float fontScale, + float x, + float y, + Component displayName, + float alpha, + int fallbackColor) { + Component converted = LegacyTextUtil.convertLegacyCodes(displayName); + List parts = TextRenderUtil.flattenStyled(converted, fallbackColor); + + renderer.begin(fontScale, false, false); + float cursorX = x; + for (Part part : parts) { + renderer.render( + part.text(), + cursorX, + y, + new RenderColor(HudRenderUtil.scaleAlpha(part.color(), alpha)), + false + ); + cursorX += (float) renderer.getWidth(part.text(), false); + } + renderer.end(); + } + + private List> legacyParts(TextRenderer renderer, + Component displayName, + float alpha, + int fallbackColor) { + Component converted = LegacyTextUtil.convertLegacyCodes(displayName); + List parts = TextRenderUtil.flattenStyled(converted, fallbackColor); + List> out = new ArrayList<>(parts.size()); + float cursorX = 0.0f; + for (Part part : parts) { + int color = HudRenderUtil.scaleAlpha(part.color(), alpha); + out.add(ScriptedListHudPanel.textPart(part.text(), color, cursorX)); + cursorX += (float) renderer.getWidth(part.text(), false); + } + return out; + } + + private AbstractClientPlayer resolvePlayerByName(String name) { + if (name == null || name.isEmpty() || mc == null || mc.level == null) { + return null; + } + for (Player player : mc.level.players()) { + if (player instanceof AbstractClientPlayer clientPlayer) { + String playerName = clientPlayer.getGameProfile() != null + ? clientPlayer.getGameProfile().name() + : clientPlayer.getName().getString(); + if (playerName != null && playerName.equalsIgnoreCase(name)) { + return clientPlayer; + } + } + } + return null; + } + + private Identifier resolveSkinByName(String name) { + AbstractClientPlayer player = resolvePlayerByName(name); + if (player != null) { + return PlayerSkinResolver.resolveProfileSkin(player.getGameProfile()); + } + + if (mc != null && mc.getConnection() != null) { + for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) { + var profile = entry.getProfile(); + if (profile == null || profile.name() == null) continue; + if (!profile.name().equalsIgnoreCase(name)) continue; + Identifier skin = PlayerSkinResolver.resolveProfileSkin(profile); + if (skin != null) return skin; + } + } + return null; + } + + private int resolveStatusColor(StaffTracker.Status status) { + return switch (status) { + case GM0 -> 0xFF55FF55; + case GM1 -> 0xFF55DDFF; + case GM2 -> 0xFFFFFF55; + case GM3 -> 0xFFFF55FF; + case VANISH -> 0xFFFF7755; + case WAITING -> 0xFFAAAAAA; + case OFFLINE -> 0xFF888888; + default -> uiMuted != 0 ? uiMuted : 0xFF888888; + }; + } + + private String formatStatus(StaffData.StaffEntry entry, long now) { + return switch (entry.status()) { + case OFFLINE -> entry.offlineSince() <= 0 ? "--:--" : formatTime(now - entry.offlineSince()); + case WAITING -> entry.lastGone() <= 0 ? "--:--" : formatTime(now - entry.lastGone()); + case VANISH -> "Vanish"; + default -> entry.status().name(); + }; + } + + private String formatTime(long ms) { + if (ms < 0) { + return "--:--"; + } + long sec = ms / 1000L; + long min = sec / 60L; + long hrs = min / 60L; + if (hrs == 0) { + return String.format("%02d:%02d", min, sec % 60L); + } + return String.format("%02d:%02d:%02d", hrs, min % 60L, sec % 60L); + } + + private int withAlpha(int rgb, int alpha) { + return (clamp255(alpha) << 24) | (rgb & 0x00FFFFFF); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private record AnimatedEntry(StaffData.StaffEntry entry, float anim) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Armor.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Armor.java new file mode 100644 index 0000000..f497efb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Armor.java @@ -0,0 +1,320 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.world.entity.EquipmentSlot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +//todo Description +@HudElementInfo( + id = "armor", + displayName = "Armor", + enabledByDefault = false, + order = 210 +) +public final class Armor extends DraggableHudElement { + + { + defaultLayout(1616.315f, 1003.6977f); + } + + + private static final int SLOTS = 4; + private static final float DEFAULT_SLOT_SIZE = 18f; + private static final float DEFAULT_SLOT_GAP = 2f; + private static final float PANEL_RADIUS = 4.0f; + private static final float GLASS_BLUR_STRENGTH = 1.0f; + private static final float GLASS_ALPHA = 1.0f; + private static final float DEFAULT_MARGIN = 16f; + private static final float DIVIDER_THICKNESS = 0.5f; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final NumberValue scaleValue = num("armor_scale", 3.68, 0.5, 5.0); + private final EnumValue layoutMode = + enumSetting("armor_layout", LayoutMode.HORIZONTAL, LayoutMode.VERTICAL, LayoutMode.HORIZONTAL); + private final BooleanValue reverse = bool("armor_reverse", false); + private final EnumValue stateMode = + enumSetting("armor_state_mode", StateMode.TEXT, StateMode.BAR, StateMode.TEXT); + private final NumberValue stateBarThreshold = + visibleWhen(num("armor_state_bar_threshold", 70, 0, 100), () -> stateMode.get() == StateMode.BAR); + private final NumberValue stateThreshold = + visibleWhen(num("armor_state_threshold", 70, 0, 100), () -> stateMode.get() == StateMode.TEXT); + private final NumberValue stateColorThreshold = + visibleWhen(num("armor_state_color_threshold", 70, 0, 100), () -> stateMode.get() == StateMode.TEXT); + private float baseX; + private float baseY; + private float slotSize; + private float slotGap; + private float slotRadius; + private float itemScale; + private boolean layoutReady; + private ItemStack[] cachedStacks; + private float renderX; + private float renderY; + + private static boolean hasAnyStacks(ItemStack[] stacks) { + if (stacks == null) return false; + for (ItemStack stack : stacks) { + if (stack != null && !stack.isEmpty()) { + return true; + } + } + return false; + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + float scale = HudScale.scale(screenW, screenH) * scaleValue.get().floatValue(); + float size = DEFAULT_SLOT_SIZE * scale; + float gap = DEFAULT_SLOT_GAP * scale; + boolean horizontal = isHorizontal(); + float w = horizontal ? SLOTS * size + (SLOTS - 1) * gap : size; + float h = horizontal ? size : SLOTS * size + (SLOTS - 1) * gap; + if (horizontal) { + this.x = screenW * 0.5f - w * 0.5f; + this.y = screenH - h - DEFAULT_MARGIN * scale; + } else { + this.x = screenW - w - DEFAULT_MARGIN * scale; + this.y = screenH * 0.5f - h * 0.5f; + } + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + layoutReady = false; + boolean preview = DraggableHudElementRegistry.isForceVisible(); + float scale = HudScale.scale(screenW, screenH) * scaleValue.get().floatValue(); + boolean horizontal = isHorizontal(); + + slotSize = DEFAULT_SLOT_SIZE * scale; + slotGap = DEFAULT_SLOT_GAP * scale; + slotRadius = PANEL_RADIUS * scale; + itemScale = scale; + + width = horizontal ? SLOTS * slotSize + (SLOTS - 1) * slotGap : slotSize; + height = horizontal ? slotSize : SLOTS * slotSize + (SLOTS - 1) * slotGap; + + if (mc == null || (mc.player == null && !preview)) { + width = 0f; + height = 0f; + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + return; + } + + ItemStack[] stacks = (mc != null && mc.player != null) ? getArmorStacks() : new ItemStack[0]; + if (preview && !hasAnyStacks(stacks)) { + stacks = previewStacks(); + } + cachedStacks = stacks; + if (!hasAnyStacks(cachedStacks) && !shouldShowWhenEmpty() && !preview) { + width = 0f; + height = 0f; + return; + } + + boolean reverse = x > screenW * 0.5f; + renderX = reverse ? (x - width) : x; + renderY = y; + baseX = renderX; + baseY = renderY; + + float glassScale = PANEL_RADIUS <= 0.0f ? 1.0f : slotRadius / PANEL_RADIUS; + HudRenderUtil.drawLiquidGlass( + baseX, + baseY, + width, + height, + slotRadius, + glassScale, + true, + GLASS_BLUR_STRENGTH, + GLASS_ALPHA + ); + renderer.roundedRectCorners( + baseX, + baseY, + width, + height, + slotRadius, + slotRadius, + slotRadius, + slotRadius, + 1.0f, + HudRenderUtil.glassSmallBackground(GLASS_ALPHA) + ); + drawSlotDividers(renderer, horizontal, scale); + + layoutReady = true; + } + + @Override + public void renderEngineForeground(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (!layoutReady || mc == null || mc.player == null || ctx == null) return; + + ItemStack[] stacks = cachedStacks != null ? cachedStacks : getArmorStacks(); + float iconSize = 16f * itemScale; + float offset = (slotSize - iconSize) * 0.5f; + boolean horizontal = isHorizontal(); + boolean reverseOrder = isReverseOrder(); + StateMode mode = stateMode.get(); + int overlayFlags = mode == StateMode.TEXT + ? Renderer2D.ITEM_OVERLAY_DURABILITY_TEXT + : Renderer2D.ITEM_OVERLAY_DURABILITY; + int durabilityThreshold = mode == StateMode.TEXT ? stateThreshold.get() : stateBarThreshold.get(); + int durabilityColorThreshold = mode == StateMode.TEXT ? stateColorThreshold.get() : stateBarThreshold.get(); + + int seed = 0; + for (int i = 0; i < stacks.length; i++) { + int idx = reverseOrder ? (SLOTS - 1 - i) : i; + ItemStack stack = stacks[idx]; + if (stack == null || stack.isEmpty()) continue; + float sx = baseX + (horizontal ? i * (slotSize + slotGap) : 0f) + offset; + float sy = baseY + (horizontal ? 0f : i * (slotSize + slotGap)) + offset; + + // Armor widget is laid out in UNSCALED_LOGICAL coordinates together with its slots. + renderer.item(stack, sx, sy, itemScale, seed++, overlayFlags, null, + durabilityThreshold, durabilityColorThreshold); + } + } + + private ItemStack[] getArmorStacks() { + return new ItemStack[]{ + mc.player.getItemBySlot(EquipmentSlot.HEAD), + mc.player.getItemBySlot(EquipmentSlot.CHEST), + mc.player.getItemBySlot(EquipmentSlot.LEGS), + mc.player.getItemBySlot(EquipmentSlot.FEET) + }; + } + + private ItemStack[] previewStacks() { + ItemStack head = new ItemStack(Items.DIAMOND_HELMET); + ItemStack chest = new ItemStack(Items.DIAMOND_CHESTPLATE); + ItemStack legs = new ItemStack(Items.DIAMOND_LEGGINGS); + ItemStack feet = new ItemStack(Items.DIAMOND_BOOTS); + head.setDamageValue(Math.max(1, head.getMaxDamage() / 3)); + chest.setDamageValue(Math.max(1, chest.getMaxDamage() / 2)); + legs.setDamageValue(Math.max(1, legs.getMaxDamage() / 4)); + feet.setDamageValue(Math.max(1, feet.getMaxDamage() / 5)); + return new ItemStack[]{head, chest, legs, feet}; + } + + private void drawSlotDividers(Renderer2D renderer, boolean horizontal, float scale) { + if (renderer == null) return; + float dividerThickness = Math.max(0.5f, DIVIDER_THICKNESS * scale); + float dividerLength = Math.max(6.0f * scale, slotSize * 0.52f); + float dividerInset = Math.max(0.0f, (slotSize - dividerLength) * 0.5f); + int divider = HudRenderUtil.scaleAlpha(0xFFFFFFFF, 0.12f); + int dividerShadow = HudRenderUtil.scaleAlpha(0xFF000000, 0.10f); + for (int i = 0; i < SLOTS - 1; i++) { + if (horizontal) { + float lineX = baseX + slotSize + i * (slotSize + slotGap) + + slotGap * 0.5f - dividerThickness * 0.5f; + float lineY = baseY + dividerInset; + renderer.quad(lineX + dividerThickness, lineY, dividerThickness, dividerLength, dividerShadow); + renderer.quad(lineX, lineY, dividerThickness, dividerLength, divider); + } else { + float lineX = baseX + dividerInset; + float lineY = baseY + slotSize + i * (slotSize + slotGap) + + slotGap * 0.5f - dividerThickness * 0.5f; + renderer.quad(lineX, lineY + dividerThickness, dividerLength, dividerThickness, dividerShadow); + renderer.quad(lineX, lineY, dividerLength, dividerThickness, divider); + } + } + } + + private boolean shouldShowWhenEmpty() { + if (hud == null) return false; + HudGlobalConfig.HeaderMode mode = hud.getHeaderMode(); + if (mode == HudGlobalConfig.HeaderMode.ALWAYS) return true; + if (mode == HudGlobalConfig.HeaderMode.CHAT) { + return mc != null && ClientScreen.current() instanceof ChatScreen; + } + return false; + } + + private boolean isHorizontal() { + return layoutMode.get() == LayoutMode.HORIZONTAL; + } + + private boolean isReverseOrder() { + return reverse.get(); + } + + @Override + public boolean contains(float mx, float my) { + return mx >= renderX && mx <= renderX + width && my >= renderY && my <= renderY + height; + } + + private enum LayoutMode implements EnumValue.IdProvider { + VERTICAL("Vertical"), + HORIZONTAL("Horizontal"); + + private final String id; + + LayoutMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum StateMode implements EnumValue.IdProvider { + BAR("Bar"), + TEXT("Text"); + + private final String id; + + StateMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/BetterChat.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/BetterChat.java new file mode 100644 index 0000000..1257c35 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/BetterChat.java @@ -0,0 +1,195 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +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 dev.loki.lovisual.config.values.BooleanMapValue; +import dev.loki.lovisual.config.values.BooleanValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.config.values.RGBColorValue; +import dev.loki.lovisual.features.gui.chat.BetterChatRenderer; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.Map; + +//todo Description +@HudElementInfo( + id = "better_chat", + displayName = "Better Chat", + enabledByDefault = true, + order = 40 +) +public final class BetterChat extends DraggableHudElement { + + { + defaultLayout(16.0f, 485.12f); + } + + + private final Minecraft mc = Minecraft.getInstance(); + private final NumberValue widthRatio = num("width_ratio", 0.54, 0.2, 0.8); + private final NumberValue fontScale = num("font_scale", 1.36, 0.6, 1.4); + private final NumberValue maxLines = num("lines", 12, 4, 20); + private final NumberValue fadeSeconds = num("fade_seconds", 14.98, 3.0, 30.0); + private final NumberValue liquidGlassAlpha = num("liquid_glass_alpha", 230, 0, 255); + private final BooleanValue hideVanilla = bool("hide_vanilla", true); + private final BooleanValue historyEnabled = bool("history_enabled", true); + private final NumberValue historyLimit = num("history_limit", 32000, 1000, 32000); + private final BooleanValue timestampsEnabled = bool("timestamps_enabled", true); + private final RGBColorValue timestampColor = + visibleWhen(colorNoAlpha("timestamp_color", "#C6D2CD"), timestampsEnabled::get); + private final BooleanMapValue timestampToggles = + visibleWhen(group("timestamp_toggles", "timestamp_toggles", Map.of( + "hover_date", true, + "hover_unix", false, + "active_only", false, + "with_seconds", false, + "with_brackets", true + )), timestampsEnabled::get); + + // Internal defaults. These remain enabled but are no longer exposed as noisy HUD settings. + private final BooleanValue cacheItemHovers = + new BooleanValue("cache_item_hovers", true); + private final BooleanValue cacheEntityHovers = + new BooleanValue("cache_entity_hovers", true); + private final NumberValue hoverCacheSize = + new NumberValue<>("hover_cache_size", 512, 64, 4096); + + public static BetterChat get() { + return DraggableHudElementRegistry.get(BetterChat.class); + } + + public static boolean isActive() { + return RuntimeGate.canRunHud() && DraggableHudElementRegistry.isEnabled(BetterChat.class); + } + + public float widthRatio() { + return widthRatio.get().floatValue(); + } + + public float fontScale() { + return fontScale.get().floatValue(); + } + + public int maxLines() { + return maxLines.get(); + } + + public float fadeSeconds() { + return fadeSeconds.get().floatValue(); + } + + public int liquidGlassAlpha() { + return liquidGlassAlpha.get(); + } + + public float liquidGlassAlphaFactor() { + return Math.max(0f, Math.min(1f, liquidGlassAlpha.get() / 255f)); + } + + public boolean hideVanilla() { + return hideVanilla.get(); + } + + public boolean historyEnabled() { + return historyEnabled.get(); + } + + public int historyLimit() { + return historyLimit.get(); + } + + public boolean timestampEnabled() { + return timestampsEnabled.get(); + } + + public int timestampColor() { + return timestampColor.getArgb(); + } + + public BooleanMapValue timestampToggles() { + return timestampToggles; + } + + public boolean timestampSeconds() { + return timestampToggles.get("with_seconds"); + } + + public boolean timestampBrackets() { + return timestampToggles.get("with_brackets"); + } + + public boolean cacheItems() { + return cacheItemHovers.get(); + } + + public boolean cacheEntities() { + return cacheEntityHovers.get(); + } + + public int cacheLimit() { + return hoverCacheSize.get(); + } + + public boolean gradientEnabled() { + return false; + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + float baseScale = HudScale.scale(screenW, screenH); + float height = Math.max(140f * baseScale, BetterChatRenderer.getLastHeight()); + this.x = 16f * baseScale; + this.y = Math.max(16f * baseScale, screenH - height - 16f * baseScale); + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + BetterChatRenderer.renderEngine(renderer, textRenderer, ctx, screenW, x, y); + float w = BetterChatRenderer.getLastWidth(); + float h = BetterChatRenderer.getLastHeight(); + this.width = Math.max(0f, w); + this.height = Math.max(0f, h); + } + + @Override + public boolean isMouseOverInteractive(float mx, float my) { + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + return BetterChatRenderer.isInteractive(mx, my); + } + + @Override + public boolean contains(float mx, float my) { + return BetterChatRenderer.contains(mx, my); + } + + @Override + public boolean onMouseClicked(float mx, float my, int button) { + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + return isMouseOverInteractive(mx, my); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Cooldowns.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Cooldowns.java new file mode 100644 index 0000000..10bfcb9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Cooldowns.java @@ -0,0 +1,702 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.features.module.modules.combat.PvpCooldowns; +import dev.loki.lovisual.mixins.accessors.ItemCooldownEntryAccessor; +import dev.loki.lovisual.mixins.accessors.ItemCooldownManagerAccessor; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.pvp.CooldownRegistry; +import dev.loki.lovisual.util.pvp.ItemCooldownSnapshot; +import dev.loki.lovisual.util.pvp.PvpState; +import dev.loki.lovisual.util.pvp.client.CooldownsState; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 30) +public final class Cooldowns extends DraggableHudElement { + + { + defaultLayout(1160.4823f, 466.0f); + } + + + private static final float HEADER_HEIGHT = 15.5f; + private static final float BODY_Y_OFFSET = 18.5f; + private static final float CONTENT_START_Y = 25.0f; + private static final float BODY_INSET_Y = 6.5f; + private static final float ROW_STEP = 11.0f; + private static final float MIN_WIDTH = 110.0f; + private static final float PANEL_RADIUS = 4.0f; + private static final float PANEL_SOFTNESS = 1.0f; + private static final float PANEL_STROKE = 0.55f; + private static final float HEADER_DIVIDER_X = 18.0f; + private static final float HEADER_DIVIDER_Y = 5.0f; + private static final float HEADER_DIVIDER_H = 6.0f; + private static final float ROW_DIVIDER_X = 15.0f; + private static final float ROW_DIVIDER_H = 6.0f; + private static final float TITLE_ICON_X = 4.0f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float COUNT_LABEL_OFFSET = 22.0f; + private static final float COUNT_VALUE_OFFSET = 2.5f; + private static final float ROW_ICON_X = 3.5f; + private static final float ROW_ICON_VISUAL_SIZE = 8.0f; + private static final float ROW_TEXT_X = 18.0f; + private static final float ROW_TIME_RIGHT = 8.0f; + private static final float ROW_CONTENT_CENTER_OFFSET = 1.9f; + private static final int BLINK_THRESHOLD_TICKS = 100; + private static final double BLINK_SPEED = 0.008; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final int PVP_ACTIVE_COLOR = 0xFFFFA500; + private static final int PVP_IDLE_COLOR = 0xFF00A0FF; + private static final int PVP_BLOCKED_COLOR = 0xFFFF5656; + private static final Item[] PREVIEW_ITEMS = new Item[]{ + Items.ENDER_EYE, Items.ENDER_PEARL, Items.SUGAR, Items.MACE, + Items.ENCHANTED_GOLDEN_APPLE, Items.TRIDENT, Items.CROSSBOW, + Items.DRIED_KELP, Items.NETHERITE_SCRAP + }; + private static ItemStack[] previewStacks; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedListHudPanel scriptedPanel = new ScriptedListHudPanel(); + private final NumberValue scaleValue = + new NumberValue<>("cooldowns_scale", 1.0, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + private final ModeValue layoutMode = + new ModeValue("cooldowns_layout", "Split Header", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + private final ModeValue colorMode = + new ModeValue("cooldowns_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("cooldowns_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final BooleanValue blink = + new BooleanValue("cooldowns_blink", true); + private final RGBAColorValue bg = + new RGBAColorValue("cooldowns_bg", "#EB111318"); + private final RGBAColorValue bg2 = + new RGBAColorValue("cooldowns_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("cooldowns_bg_alpha", 185, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("cooldowns_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("cooldowns_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("cooldowns_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("cooldowns_blur_alpha", 255, 0, 255); + private final BooleanValue headerIconPulse = + new BooleanValue("cooldowns_header_icon_pulse", true); + private final NumberValue headerIconPulseSpeed = + new NumberValue<>("cooldowns_header_icon_pulse_speed", 18, 1, 60); + private final NumberValue headerIconPulseIntensity = + new NumberValue<>("cooldowns_header_icon_pulse_intensity", 100, 0, 100); + + private final Map entryAnim = new LinkedHashMap<>(); + private final Map lastEntries = new LinkedHashMap<>(); + private final List pendingItemTasks = new ArrayList<>(9); + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private boolean foregroundReady; + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiDivider; + private int uiBlurTint; + + public Cooldowns() { + super("cooldowns", "Cooldowns", true); + } + + private static int clamp255(int value) { + return Math.max(0, Math.min(255, value)); + } + + + private static ItemStack[] previewStacks() { + if (previewStacks == null || previewStacks.length != PREVIEW_ITEMS.length) { + previewStacks = buildPreviewStacks(); + } + return previewStacks; + } + + private static ItemStack[] buildPreviewStacks() { + ItemStack[] stacks = new ItemStack[PREVIEW_ITEMS.length]; + for (int i = 0; i < PREVIEW_ITEMS.length; i++) { + stacks[i] = new ItemStack(PREVIEW_ITEMS[i]); + } + return stacks; + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(blink)); + defs.add(SettingDef.bool(headerIconPulse)); + defs.add(SettingDef.number(headerIconPulseSpeed).visibleWhen(headerIconPulse::get)); + defs.add(SettingDef.number(headerIconPulseIntensity).visibleWhen(headerIconPulse::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 132.0f; + this.y = 20.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + foregroundReady = false; + pendingItemTasks.clear(); + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !forceVisible)) { + width = 0.0f; + height = 0.0f; + return; + } + + boolean chatPreview = ClientScreen.current() instanceof ChatScreen; + List rows = collectRows(); + List animatedRows = animateRows(rows); + boolean showExampleRow = rows.isEmpty() && (forceVisible || chatPreview); + boolean showWidget = !rows.isEmpty() || showExampleRow; + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + if (animatedRows.isEmpty() && !showExampleRow && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + TextRenderer headerIconRenderer = Fonts.renderer("IconsNur", FontInfo.Type.Regular, TextRenderer.get()); + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer rowTextRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (rowTextRenderer == null) rowTextRenderer = textRenderer; + if (headerIconRenderer == null) headerIconRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(scaleValue); + float rowStep = ROW_STEP * baseScale; + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + + headerIconRenderer.begin(fontScale, true, false); + float headerIconH = (float) headerIconRenderer.getHeight(false); + headerIconRenderer.end(); + + headerTextRenderer.begin(fontScale, true, false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Cooldowns", false); + headerTextRenderer.end(); + + rowTextRenderer.begin(fontScale, true, false); + float rowTextH = (float) rowTextRenderer.getHeight(false); + float maxWidth = MIN_WIDTH * baseScale; + if (showExampleRow) { + float previewWidth = (float) rowTextRenderer.getWidth("Example Cooldowns**:**", false) + (30.0f * baseScale); + maxWidth = Math.max(maxWidth, previewWidth); + } else { + for (AnimatedRow animatedRow : animatedRows) { + Row row = animatedRow.row(); + float widthCandidate = (float) rowTextRenderer.getWidth(row.name() + row.duration(), false) + (30.0f * baseScale); + maxWidth = Math.max(maxWidth, widthCandidate); + } + } + rowTextRenderer.end(); + + rowTextRenderer.begin(fontScale * 0.92f, false, false); + String activeCountText = Integer.toString(rows.size()); + float activeLabelWidth = (float) rowTextRenderer.getWidth("Active:", false); + float activeCountWidth = (float) rowTextRenderer.getWidth(activeCountText, false); + rowTextRenderer.end(); + + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + activeLabelWidth + activeCountWidth + + ((COUNT_LABEL_OFFSET + 12.0f) * baseScale); + maxWidth = Math.max(maxWidth, headerWidth); + + float contentHeight = showExampleRow ? rowStep : totalAnimatedHeight(animatedRows, rowStep); + float targetHeight = (showExampleRow || !animatedRows.isEmpty()) + ? (CONTENT_START_Y * baseScale + contentHeight) + : (HEADER_HEIGHT * baseScale); + + displayWidth = HudRenderUtil.animateDimension(displayWidth, maxWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + if (!showWidget && widgetScale <= 0.001f && animatedRows.isEmpty()) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderIconH = headerIconH * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawRowTextH = rowTextH * drawScale; + int resolvedHeaderIconColor = uiCounter; + if (headerIconPulse.get()) { + int pulsedHeaderIconColor = HudTextEffects.animatedColor( + uiCounter, + HudTextEffects.Effect.PULSE, + headerIconPulseSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + resolvedHeaderIconColor = HudRenderUtil.mixColor( + uiCounter, + pulsedHeaderIconColor, + headerIconPulseIntensity.get() / 100.0f + ); + } + + List> panelRows = ScriptedListHudPanel.rows(); + rowTextRenderer.begin(drawFontScale, false, false); + if (showExampleRow) { + ItemStack stack = previewStacks()[(int) ((System.currentTimeMillis() / 1000L) % previewStacks().length)]; + panelRows.add(ScriptedListHudPanel.row( + "preview", + "item", + BuiltInRegistries.ITEM.getKey(stack.getItem()).toString(), + List.of(ScriptedListHudPanel.textPart("Example Cooldowns", withAlpha(uiText & 0x00FFFFFF, 255), 0.0f)), + "**:**", + withAlpha(uiCounter & 0x00FFFFFF, 255), + withAlpha(uiMuted & 0x00FFFFFF, 130), + 1.0f + )); + } else { + for (AnimatedRow animatedRow : animatedRows) { + float anim = animatedRow.anim(); + if (anim <= 0.01f) continue; + Row rowData = animatedRow.row(); + int rowBaseAlpha = clamp255(Math.round(255.0f * anim)); + int iconAlpha = getBlinkAlpha(rowData, rowBaseAlpha, 110); + int textAlpha = getBlinkAlpha(rowData, rowBaseAlpha, 95); + int timeAlpha = getBlinkAlpha(rowData, rowBaseAlpha, 60); + panelRows.add(ScriptedListHudPanel.row( + rowData.key(), + "item", + BuiltInRegistries.ITEM.getKey(rowData.stack().getItem()).toString(), + List.of(ScriptedListHudPanel.textPart(rowData.name(), withAlpha(uiText & 0x00FFFFFF, textAlpha), 0.0f)), + rowData.duration(), + withAlpha(rowData.timeColor() & 0x00FFFFFF, timeAlpha), + withAlpha(uiMuted & 0x00FFFFFF, Math.max(26, iconAlpha - 120)), + anim + )); + } + } + rowTextRenderer.end(); + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedListHudPanel.Panel( + ScriptedListHudPanel.COOLDOWNS, + new ScriptedListHudPanel.Palette(uiHeaderLeft, uiHeaderRight, uiBodyLeft, uiBodyRight, + uiOutline, uiText, uiMuted, uiCounter, uiTitleText, uiDivider, uiBlurTint), + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderIconH, + drawHeaderTextH, + drawRowTextH, + activeLabelWidth * drawScale, + activeCountWidth * drawScale, + rows.size(), + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + resolvedHeaderIconColor, + HudPanelLayoutModes.current(layoutMode), + panelRows + ) + ); + foregroundReady = false; + } + + @Override + public void renderEngineForeground(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (!foregroundReady || ctx == null || pendingItemTasks.isEmpty()) { + pendingItemTasks.clear(); + return; + } + for (QueuedItemIcon task : pendingItemTasks) { + // Cooldowns icons live in the same logical HUD space as the panel geometry/text. + renderer.item(task.stack(), task.x(), task.y(), task.scale(), task.seed(), Renderer2D.ITEM_OVERLAY_NONE, null); + } + pendingItemTasks.clear(); + } + + @Override + public boolean isDraggable() { + return super.isDraggable(); + } + + private void renderExampleRow(Renderer2D renderer, + TextRenderer rowTextRenderer, + float fontScale, + float rowTextH, + float baseScale, + float renderWidth, + float centerY, + float renderX) { + ItemStack stack = previewStacks()[(int) ((System.currentTimeMillis() / 1000L) % previewStacks().length)]; + int textColor = withAlpha(uiText & 0x00FFFFFF, 255); + int timeColor = withAlpha(uiCounter & 0x00FFFFFF, 255); + int dividerColor = withAlpha(uiMuted & 0x00FFFFFF, 130); + float rowContentCenterY = centerY + (ROW_CONTENT_CENTER_OFFSET * baseScale); + + queueItemIcon(pendingItemTasks, stack, renderX + (ROW_ICON_X * baseScale), rowContentCenterY, baseScale, 0); + + renderer.quad( + renderX + (ROW_DIVIDER_X * baseScale), + rowContentCenterY - ((ROW_DIVIDER_H * baseScale) * 0.5f), + Math.max(0.5f, 0.5f * baseScale), + ROW_DIVIDER_H * baseScale, + dividerColor + ); + + rowTextRenderer.begin(fontScale, false, false); + float textY = rowContentCenterY - (rowTextH * 0.5f); + rowTextRenderer.render("Example Cooldowns", + renderX + (ROW_TEXT_X * baseScale), textY, new RenderColor(textColor), false); + float durationWidth = (float) rowTextRenderer.getWidth("**:**", false); + rowTextRenderer.render("**:**", + renderX + renderWidth - durationWidth - (ROW_TIME_RIGHT * baseScale), + textY, + new RenderColor(timeColor), + false + ); + rowTextRenderer.end(); + } + + private void queueItemIcon(List itemTasks, + ItemStack stack, + float iconX, + float centerY, + float baseScale, + int seed) { + if (itemTasks == null) { + return; + } + if (stack.isEmpty()) { + return; + } + float iconSize = ROW_ICON_VISUAL_SIZE * baseScale; + float itemScale = iconSize / 16.0f; + float itemDrawX = iconX; + float itemDrawY = centerY - (16.0f * itemScale * 0.5f); + itemTasks.add(new QueuedItemIcon(stack.copy(), itemDrawX, itemDrawY, itemScale, seed)); + } + + private List collectRows() { + List rows = new ArrayList<>(); + if (mc != null && mc.player != null) { + var manager = mc.player.getCooldowns(); + int nowTick = ((ItemCooldownManagerAccessor) manager).getTick(); + + ((ItemCooldownManagerAccessor) manager).getEntries().forEach((Identifier id, Object entry) -> { + int remainingTicks = ((ItemCooldownEntryAccessor) entry).getEndTick() - nowTick; + if (remainingTicks <= 0) return; + Item item = id != null ? BuiltInRegistries.ITEM.getValue(id) : Items.AIR; + ItemStack stack = new ItemStack(item); + String name = stack.isEmpty() ? (id != null ? id.getPath() : "Unknown") : stack.getHoverName().getString(); + String key = "vanilla:" + (id != null ? id.toString() : name); + rows.add(new Row(key, stack, name, formatSeconds(remainingTicks / 20.0f), remainingTicks, + uiMuted != 0 ? uiMuted : theme().textMuted())); + }); + + PvpCooldowns pvpCooldowns = Modules.get(PvpCooldowns.class); + boolean pvpEnabled = pvpCooldowns != null && pvpCooldowns.isSystemEnabled(); + if (pvpEnabled && pvpCooldowns.shouldRenderWidget()) { + boolean inPvp = CooldownsState.MANAGER.isInPvp(); + int stateColor = inPvp ? PVP_ACTIVE_COLOR : PVP_IDLE_COLOR; + for (Item item : CooldownRegistry.trackedItems()) { + ItemCooldownSnapshot snapshot = CooldownsState.MANAGER.snapshot(item); + if (!snapshot.visible()) continue; + + float seconds = snapshot.cooling() + ? snapshot.cooldownRemainingMs() / 1000.0f + : snapshot.useWindowRemainingMs() / 1000.0f; + String text = snapshot.cooling() + ? formatSeconds(seconds) + : snapshot.compactText(); + int remainingTicks = snapshot.cooling() + ? Math.max(1, Math.round(seconds * 20.0f)) + : Math.max(1, Math.round(Math.max(1.0f, seconds) * 20.0f)); + ItemStack stack = new ItemStack(item); + rows.add(new Row( + "pvp:" + BuiltInRegistries.ITEM.getKey(item), + stack, + stack.getHoverName().getString(), + text, + remainingTicks, + snapshot.cooling() ? stateColor : (uiMuted != 0 ? uiMuted : theme().textMuted()) + )); + } + } + + if (pvpEnabled && PvpState.isActive()) { + Float secondsLeft = PvpState.getSecondsLeft(); + if (secondsLeft != null && secondsLeft > 0.0f) { + int remainingTicks = Math.max(1, Math.round(secondsLeft * 20.0f)); + ItemStack pearlStack = new ItemStack(Items.ENDER_PEARL); + rows.add(new Row( + "pvpban:" + BuiltInRegistries.ITEM.getKey(Items.ENDER_PEARL), + pearlStack, + pearlStack.getHoverName().getString(), + formatSeconds(secondsLeft), + remainingTicks, + PVP_BLOCKED_COLOR + )); + ItemStack chorusStack = new ItemStack(Items.CHORUS_FRUIT); + rows.add(new Row( + "pvpban:" + BuiltInRegistries.ITEM.getKey(Items.CHORUS_FRUIT), + chorusStack, + chorusStack.getHoverName().getString(), + formatSeconds(secondsLeft), + remainingTicks, + PVP_BLOCKED_COLOR + )); + } + } + } + + rows.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.name(), b.name())); + return rows; + } + + private List animateRows(List rows) { + Map current = new LinkedHashMap<>(); + for (Row row : rows) { + current.put(row.key(), row); + } + + Map merged = new LinkedHashMap<>(); + merged.putAll(current); + for (Map.Entry entry : lastEntries.entrySet()) { + merged.putIfAbsent(entry.getKey(), entry.getValue()); + } + lastEntries.clear(); + lastEntries.putAll(merged); + + List toRemove = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + String key = entry.getKey(); + float target = current.containsKey(key) ? 1.0f : 0.0f; + float anim = entryAnim.getOrDefault(key, 0.0f); + anim = AnimationUtility.approach(anim, target, AnimationUtility.deltaTime(), 12.0f); + anim = AnimationUtility.snap(anim, target, 0.02f); + if (anim <= 0.0f && target <= 0.0f) { + toRemove.add(key); + } else { + entryAnim.put(key, anim); + } + } + for (String key : toRemove) { + entryAnim.remove(key); + lastEntries.remove(key); + } + + List out = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + float anim = entryAnim.getOrDefault(entry.getKey(), 0.0f); + if (anim > 0.01f) { + out.add(new AnimatedRow(entry.getValue(), anim)); + } + } + return out; + } + + private float totalAnimatedHeight(List rows, float rowStep) { + float out = 0.0f; + for (AnimatedRow row : rows) { + out += rowStep * row.anim(); + } + return out; + } + + private int getBlinkAlpha(Row row, int baseAlpha, int minAlphaFloor) { + if (!blink.get()) { + return baseAlpha; + } + if (row.remainingTicks() <= 0 || row.remainingTicks() > BLINK_THRESHOLD_TICKS) { + return baseAlpha; + } + + double wave = Math.sin(System.currentTimeMillis() * BLINK_SPEED); + float factor = (float) ((wave + 1.0) * 0.5); + int minAlpha = Math.max(0, Math.min(baseAlpha, minAlphaFloor)); + return clamp255(Math.round(minAlpha + (baseAlpha - minAlpha) * (1.0f - factor))); + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + return; + } + + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + private String formatSeconds(float seconds) { + if (seconds >= 10.0f) { + return String.format("%.0fs", seconds); + } + return String.format("%.1fs", Math.round(seconds * 10.0f) / 10.0f); + } + + private int withAlpha(int rgb, int alpha) { + return (clamp255(alpha) << 24) | (rgb & 0x00FFFFFF); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private record Row(String key, ItemStack stack, String name, String duration, int remainingTicks, int timeColor) { + } + + private record AnimatedRow(Row row, float anim) { + } + + private record QueuedItemIcon(ItemStack stack, float x, float y, float scale, int seed) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Coordinates.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Coordinates.java new file mode 100644 index 0000000..3653b1a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Coordinates.java @@ -0,0 +1,333 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; +import net.minecraft.world.level.Level; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +//todo Description +@HudElementInfo( + id = "xyz", + displayName = "XYZ", + enabledByDefault = true, + order = 100 +) +public final class Coordinates extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier COORDS_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/coords.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 11f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float SEGMENT_GAP = 8f; + private static final float LABEL_VALUE_GAP = 2f; + private static final float RIGHT_PAD = 5f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final EnumValue netherMode = + enumSetting("xyz_nether", NetherMode.ON, NetherMode.OFF, NetherMode.ON, NetherMode.ONLY_NETHER); + private final NumberValue scale = num("xyz_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("xyz_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("xyz_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue labelColor = visibleWhen(colorNoAlpha("xyz_label_color", "#9B9B9B"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("xyz_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue extraColor = visibleWhen(colorNoAlpha("xyz_extra_color", "#BEBEBE"), this::isCustomMode); + private final ModeValue bgEffect = mode("xyz_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("xyz_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("xyz_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("xyz_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("xyz_bg_alpha", 184, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("xyz_blur_alpha", 255, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("xyz_label_effect", HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("xyz_label_effect_speed", 18, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("xyz"); + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiLabelColor; + private int uiValueColor; + private int uiExtraColor; + private float displayXValueWidth = -1f; + private float displayYValueWidth = -1f; + private float displayZValueWidth = -1f; + private float displayExtraWidth = -1f; + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = screenH - 92f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !preview)) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + boolean inNether = mc != null && mc.level != null && mc.level.dimension() == Level.NETHER; + double rawX = mc != null && mc.player != null ? mc.player.getX() : 0.0; + double rawY = mc != null && mc.player != null ? mc.player.getY() : 0.0; + double rawZ = mc != null && mc.player != null ? mc.player.getZ() : 0.0; + int px = Mth.floor(rawX); + int py = Mth.floor(rawY); + int pz = Mth.floor(rawZ); + boolean showNether = shouldShowNether(inNether); + int hx = showNether ? (int) (rawX * (inNether ? 8.0 : 0.125)) : 0; + int hz = showNether ? (int) (rawZ * (inNether ? 8.0 : 0.125)) : 0; + updatePalette(); + + String xValue = Integer.toString(px); + String yValue = Integer.toString(py); + String zValue = Integer.toString(pz); + String extra = showNether ? "[" + hx + " " + hz + "]" : ""; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer tr = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * this.scale.get().floatValue(); + + tr.begin(drawScale, true, false); + float labelXW = (float) tr.getWidth("x", false); + float labelYW = (float) tr.getWidth("y", false); + float labelZW = (float) tr.getWidth("z", false); + float valueXW = (float) tr.getWidth(xValue, false); + float valueYW = (float) tr.getWidth(yValue, false); + float valueZW = (float) tr.getWidth(zValue, false); + float extraW = showNether ? (float) tr.getWidth(extra, false) : 0f; + float textH = (float) tr.getHeight(false); + tr.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconSize = ICON_SIZE * drawScale; + float cursorX = x + TEXT_LEFT_X * drawScale; + float gap = SEGMENT_GAP * drawScale; + float pairGap = LABEL_VALUE_GAP * drawScale; + displayXValueWidth = smoothWidth(displayXValueWidth, valueXW); + displayYValueWidth = smoothWidth(displayYValueWidth, valueYW); + displayZValueWidth = smoothWidth(displayZValueWidth, valueZW); + displayExtraWidth = showNether ? smoothWidth(displayExtraWidth, extraW) : 0.0f; + float boxW = (cursorX - x) + + labelXW + pairGap + displayXValueWidth + + gap + + labelYW + pairGap + displayYValueWidth + + gap + + labelZW + pairGap + displayZValueWidth + + (showNether ? gap + displayExtraWidth : 0f) + + RIGHT_PAD * drawScale; + + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - textH) * 0.5f; + + int iconColor = uiIconColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + iconColor = HudTextEffects.animatedColor(iconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(COORDS_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, iconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiLabelColor, 0x52)); + scriptModel.value().set(xValue + " " + yValue + " " + zValue, "Onest", drawScale, + cursorX - baseX, textY - baseY, boxW - (cursorX - baseX), uiValueColor); + scriptModel.unit().hidden(); + if (showNether) { + scriptModel.extra().set(extra, "OnestMedium", drawScale, + boxW - RIGHT_PAD * drawScale - displayExtraWidth, textY - baseY, displayExtraWidth, uiExtraColor); + } else { + scriptModel.extra().hidden(); + } + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("x", px) + .data("y", py) + .data("z", pz) + .data("xText", xValue) + .data("yText", yValue) + .data("zText", zValue) + .data("showNether", showNether) + .data("netherX", hx) + .data("netherZ", hz) + .data("netherText", extra) + .data("labelXW", labelXW) + .data("labelYW", labelYW) + .data("labelZW", labelZW) + .data("valueXW", displayXValueWidth) + .data("valueYW", displayYValueWidth) + .data("valueZW", displayZValueWidth) + .data("xyzExtraW", displayExtraWidth) + .data("labelColor", CompactHudStatModel.colorString(uiLabelColor)) + .data("valueColor", CompactHudStatModel.colorString(uiValueColor)); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private boolean shouldShowNether(boolean inNether) { + NetherMode mode = netherMode.get(); + if (mode == NetherMode.ON) return true; + if (mode == NetherMode.ONLY_NETHER) return !inNether; + return false; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiLabelColor = theme().textMuted(); + uiValueColor = theme().textPrimary(); + uiExtraColor = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.18f); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiLabelColor = labelColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiExtraColor = extraColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private enum NetherMode implements EnumValue.IdProvider { + OFF("Off"), + ON("On"), + ONLY_NETHER("OnlyNether"); + + private final String id; + + NetherMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Fps.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Fps.java new file mode 100644 index 0000000..0ceca84 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Fps.java @@ -0,0 +1,305 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.FastFps; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "fps", + displayName = "FPS", + enabledByDefault = true, + order = 120 +) +public final class Fps extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier FPS_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/fps.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 12.5f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float VALUE_UNIT_GAP = 2f; + private static final float RIGHT_PAD = 5f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + private static final long DIGIT_ANIMATION_DURATION_MS = 200L; + private static final float DIGIT_ANIMATION_OFFSET = 8.0f; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final NumberValue scale = num("fps_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("fps_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("fps_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("fps_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue metaColor = visibleWhen(colorNoAlpha("fps_meta_color", "#9B9B9B"), this::isCustomMode); + private final ModeValue bgEffect = mode("fps_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("fps_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("fps_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("fps_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("fps_bg_alpha", 133, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("fps_blur_alpha", 255, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("fps_label_effect", HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("fps_label_effect_speed", 18, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final BooleanValue digitAnimation = bool("fps_digit_animation", false); + private final NumberValue digitPollMs = + visibleWhen(num("fps_digit_poll_ms", 495, 50, 1000), digitAnimation::get); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("fps"); + + private String shownFps = "0"; + private String previousShownFps = ""; + private long digitAnimStartMs = 0L; + private long lastDigitPollMs = 0L; + private float displayValueWidth = -1f; + + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private int uiMetaColor; + + private static String widerText(String a, String b) { + return a != null && b != null && a.length() < b.length() ? b : a; + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 20f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + int fps = Math.max(0, FastFps.getFps()); + long nowMs = Util.getMillis(); + String value = resolveDisplayedFps(Integer.toString(fps), nowMs); + String unit = "FPS"; + float digitAnimProgress = 1.0f; + if (digitAnimation.get() && digitAnimStartMs > 0L) { + digitAnimProgress = AnimationUtility.clamp01( + (nowMs - digitAnimStartMs) / (float) DIGIT_ANIMATION_DURATION_MS + ); + } + String widthValue = digitAnimation.get() ? widerText(value, previousShownFps) : value; + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + TextRenderer metaRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, valueRenderer); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + valueRenderer.begin(drawScale, true, false); + float valueW = (float) valueRenderer.getWidth(widthValue, false); + float valueH = (float) valueRenderer.getHeight(false); + valueRenderer.end(); + + metaRenderer.begin(drawScale, true, false); + float unitW = (float) metaRenderer.getWidth(unit, false); + float unitH = (float) metaRenderer.getHeight(false); + metaRenderer.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconSize = ICON_SIZE * drawScale; + float valueX = x + TEXT_LEFT_X * drawScale; + float unitGap = VALUE_UNIT_GAP * drawScale; + displayValueWidth = smoothWidth(displayValueWidth, valueW); + float boxW = (valueX - x) + displayValueWidth + unitGap + unitW + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - Math.max(valueH, unitH)) * 0.5f; + float unitX = valueX + displayValueWidth + unitGap; + + int resolvedIconColor = uiIconColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(FPS_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiMetaColor, 0x52)); + scriptModel.value().set(value, "Onest", drawScale, valueX - baseX, textY - baseY, displayValueWidth, uiValueColor); + scriptModel.unit().set(unit, "OnestMedium", drawScale, unitX - baseX, textY - baseY, unitW, uiMetaColor); + scriptModel.extra().hidden(); + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(digitAnimation.get(), previousShownFps, digitAnimProgress, DIGIT_ANIMATION_OFFSET); + scriptModel.data("fps", fps) + .data("valueText", value) + .data("unitText", unit); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + uiMetaColor = theme().textMuted(); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiMetaColor = metaColor.getArgb(); + } + + private String resolveDisplayedFps(String rawValue, long nowMs) { + if (!digitAnimation.get()) { + shownFps = rawValue; + previousShownFps = rawValue; + digitAnimStartMs = 0L; + lastDigitPollMs = nowMs; + return rawValue; + } + + if (shownFps == null || shownFps.isEmpty()) { + shownFps = rawValue; + previousShownFps = rawValue; + digitAnimStartMs = 0L; + lastDigitPollMs = nowMs; + return shownFps; + } + + if (nowMs - lastDigitPollMs < digitPollMs.get()) { + return shownFps; + } + + lastDigitPollMs = nowMs; + if (!rawValue.equals(shownFps)) { + previousShownFps = shownFps; + shownFps = rawValue; + digitAnimStartMs = nowMs; + } + return shownFps; + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/GameTime.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/GameTime.java new file mode 100644 index 0000000..ecea8f7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/GameTime.java @@ -0,0 +1,307 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Util; +import net.minecraft.world.level.biome.Biome; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.features.module.modules.visuals.WorldTweaks; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "game_time", + displayName = "Game Time", + enabledByDefault = false, + order = 180 +) +public final class GameTime extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float RIGHT_PAD = 5f; + private static final float ICON_SCALE = 0.92f; + private static final String WIDTH_TEMPLATE = "00:00"; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + private static final long SUNRISE_START = 23000L; + private static final long SUNRISE_END = 1000L; + private static final long SUNSET_START = 12000L; + private static final long SUNSET_END = 13000L; + private static final int ICON_SUNRISE = 0xEA0E; + private static final int ICON_SUN = 0xEA04; + private static final int ICON_SUNSET = 0xEA0F; + private static final int ICON_RAIN_DAY = 0xEA01; + private static final int ICON_RAIN_NIGHT = 0xEA09; + private static final int ICON_THUNDER_DAY = 0xEA05; + private static final int ICON_THUNDER_NIGHT = 0xEA0C; + private static final int ICON_SNOW_DAY = 0xEA03; + private static final int ICON_SNOW_NIGHT = 0xEA0B; + private static final int ICON_THUNDER_SNOW_DAY = 0xEA02; + private static final int ICON_THUNDER_SNOW_NIGHT = 0xEA0A; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final BooleanValue showWeather = bool("game_time_weather", "weather", true); + private final NumberValue scale = num("game_time_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("game_time_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("game_time_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("game_time_value_color", "#FFFFFF"), this::isCustomMode); + private final ModeValue bgEffect = mode("game_time_bg_effect", "bg_effect", "None", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("game_time_bg", "#EB111318"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("game_time_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("game_time_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("game_time_bg_alpha", 235, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("game_time_blur_alpha", 140, 0, 255), this::hasEffect); + private final EnumValue iconEffect = + enumSetting("game_time_icon_effect", HudTextEffects.Effect.MIX, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue iconEffectSpeed = + visibleWhen(num("game_time_icon_effect_speed", 18, 1, 60), + () -> iconEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("game_time"); + + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private float displayTimeWidth = -1f; + + private static String iconString(int codepoint) { + return new String(Character.toChars(codepoint)); + } + + private static TextRenderer getWeatherIcons() { + return Fonts.renderer("WeatherIcons", FontInfo.Type.Regular, TextRenderer.get()); + } + + private static String formatTime(long time) { + int hours = (int) ((time / 1000 + 6) % 24); + int minutes = (int) ((time % 1000) * 60 / 1000); + return String.format("%02d:%02d", hours, minutes); + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 76f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if ((mc == null || mc.level == null) && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + long worldTime = mc != null && mc.level != null ? (WorldTweaks.getServerTimeOfDay() % 24000L) : 6000L; + String icon = iconString(resolveIcon(worldTime)); + String timeText = formatTime(worldTime); + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer iconRenderer = getWeatherIcons(); + if (iconRenderer == null) iconRenderer = fallback; + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + float iconScale = drawScale * ICON_SCALE; + iconRenderer.begin(iconScale, true, false); + float iconW = (float) iconRenderer.getWidth(icon, false); + float iconH = (float) iconRenderer.getHeight(false); + iconRenderer.end(); + + valueRenderer.begin(drawScale, true, false); + float timeW = (float) valueRenderer.getWidth(timeText, false); + float timeH = (float) valueRenderer.getHeight(false); + float reservedTimeW = (float) valueRenderer.getWidth(WIDTH_TEMPLATE, false); + valueRenderer.end(); + + displayTimeWidth = Math.max(smoothWidth(displayTimeWidth, timeW), reservedTimeW); + float boxH = BOX_HEIGHT * drawScale; + float boxW = TEXT_LEFT_X * drawScale + displayTimeWidth + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconH) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textX = baseX + TEXT_LEFT_X * drawScale; + float textY = baseY + (boxH - timeH) * 0.5f; + + int resolvedIconColor = uiIconColor; + boolean useIconEffect = iconEffect.get() != HudTextEffects.Effect.NONE; + float nowSec = useIconEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useIconEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, iconEffect.get(), + iconEffectSpeed.get(), nowSec, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().glyph(icon, "WeatherIcons", iconX - baseX, iconY - baseY, iconW, iconH, iconScale, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiValueColor, 0x52)); + scriptModel.value().set(timeText, "Onest", drawScale, textX - baseX, textY - baseY, displayTimeWidth, uiValueColor); + scriptModel.unit().hidden(); + scriptModel.extra().hidden(); + scriptModel.animation().setLabelEffect(iconEffect.get().name(), iconEffectSpeed.get(), nowSec) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("worldTime", worldTime) + .data("timeText", timeText) + .data("weatherIcon", icon) + .data("showWeather", showWeather.get()); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private int resolveIcon(long time) { + if (!showWeather.get()) return resolveClearIcon(time); + if (mc == null || mc.level == null || mc.player == null) return resolveClearIcon(time); + + boolean thunder = WorldTweaks.isServerThundering(mc.level); + boolean rain = WorldTweaks.isServerRaining(mc.level); + if (!thunder && !rain) return resolveClearIcon(time); + + boolean day = time < 12000L; + boolean snow = isSnowingAtPlayer(); + if (thunder) { + return snow ? (day ? ICON_THUNDER_SNOW_DAY : ICON_THUNDER_SNOW_NIGHT) + : (day ? ICON_THUNDER_DAY : ICON_THUNDER_NIGHT); + } + if (snow) return day ? ICON_SNOW_DAY : ICON_SNOW_NIGHT; + return day ? ICON_RAIN_DAY : ICON_RAIN_NIGHT; + } + + private int resolveClearIcon(long time) { + if (time >= SUNRISE_START || time < SUNRISE_END) return ICON_SUNRISE; + if (time >= SUNSET_START && time < SUNSET_END) return ICON_SUNSET; + return ICON_SUN; + } + + private boolean isSnowingAtPlayer() { + if (mc == null || mc.level == null || mc.player == null) return false; + BlockPos pos = mc.player.blockPosition(); + Biome.Precipitation precipitation = mc.level.getBiome(pos).value() + .getPrecipitationAt(pos, mc.level.getSeaLevel()); + return precipitation == Biome.Precipitation.SNOW; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudNotifier.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudNotifier.java new file mode 100644 index 0000000..59c89a5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudNotifier.java @@ -0,0 +1,1117 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + + +import dev.loki.lovisual.features.theme.Theme; +import com.mojang.blaze3d.platform.Window; +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.resources.Identifier; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.events.impl.RenderPrewarmCollectEvent; +import dev.loki.lovisual.config.values.BooleanValue; +import dev.loki.lovisual.config.values.ModeValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.features.module.ModuleStateListener; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.runtime.RuntimeGate; +import dev.loki.lovisual.util.wav.CustomSoundEngine; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +@HudElementRegister(order = 80) +public final class HudNotifier extends DraggableHudElement implements ModuleStateListener { + + private static final HudNotifier INSTANCE = new HudNotifier(); + + private static final int MAX_TOASTS = 6; + private static final float BASE_MARGIN = 9f; + private static final float BASE_GAP = 3f; + private static final float BASE_BOTTOM_OFFSET = 160f; + + private static final float BASE_RADIUS = 4f; + private static final float BASE_BOX_HEIGHT = 16f; + private static final float BASE_LEFT_PAD = 7f; + private static final float BASE_RIGHT_PAD = 9f; + private static final float BASE_ICON_PIPE_GAP = 5f; + private static final float BASE_TOGGLE_PIPE_GAP = 4.5f; + private static final float BASE_PIPE_TEXT_GAP = 6f; + private static final float BASE_TOGGLE_W = 20f; + private static final float BASE_TOGGLE_H = 12f; + private static final float BASE_PROGRESS_H = 1.35f; + private static final float GLASS_BLUR_ALPHA = 255f; + private static final float GLASS_PASS_ALPHA = 255f; + private static final float TEXT_ALPHA_FACTOR = 0.88f; + private static final float PIPE_ALPHA_FACTOR = 0.72f; + private static final float TEXT_DARKEN_MIN = 0.78f; + private static final float TEXT_DARKEN_MAX = 1.00f; + private static final float TEXT_ANIM_SPEED = 2.2f; + private static final float LAYOUT_SMOOTH_SPEED = 14.0f; + private static final float BASE_TOAST_SLIDE = 7.0f; + private static final float LAYOUT_SNAP_EPS = 0.12f; + + private static final String ICON_NO = "I"; + private static final String ICON_INFO = "J"; + private static final String ICON_YES = "K"; + private static final String ICON_WARN = "L"; + + private static final int ENTER_MS = 400; + private static final int DEFAULT_EXIT_MS = 260; + private static final long PREVIEW_ROTATE_MS = 1000L; + private static final int TOGGLE_TRANSITION_MS = 420; + private static final String PREVIEW_KEY = "preview"; + private static final PreviewToast[] PREVIEW_EXAMPLES = new PreviewToast[]{ + new PreviewToast("Module - enabled!", NotifyType.YES, ToastVisual.MODULE_TOGGLE, true), + new PreviewToast("Module - disabled!", NotifyType.NO, ToastVisual.MODULE_TOGGLE, false) + }; + + private final NumberValue durationMs = new NumberValue<>("duration_ms", 2000, 500, 6000); + private final NumberValue fadeMs = new NumberValue<>("fade_ms", 250, 0, 1500); + private final NumberValue scaleValue = new NumberValue<>("scale", 1.35, 0.5, 3.0); + private final BooleanValue soundEnabled = new BooleanValue("sound", true); + private final ModeValue soundMode = new ModeValue("sound_mode", "2", "1", "2", "3"); + private final NumberValue soundVolume = new NumberValue<>("sound_volume", 0.47, 0.05, 1.0); + + private final Minecraft mc = Minecraft.getInstance(); + private final List toasts = new ArrayList<>(); + private final RenderColor colorTmp = new RenderColor(0xFFFFFFFF); + private boolean stackLayoutReady; + private float stackWidthAnim; + private float stackHeightAnim; + + private HudNotifier() { + super("hud_notifier", "HudNotifier", true); + ModuleManager.addListener(this); + } + + public static void pushMessage(String text) { + INSTANCE.enqueue(null, text, null, null); + } + + public static void pushMessage(String text, NotifyType type) { + INSTANCE.enqueue(null, text, null, type); + } + + public static void pushState(String text, boolean enabled) { + INSTANCE.enqueue(null, text, enabled, null); + } + + public static void pushOrUpdateMessage(String key, String text) { + INSTANCE.enqueue(key, text, null, null); + } + + public static void pushOrUpdateMessage(String key, String text, NotifyType type) { + INSTANCE.enqueue(key, text, null, type); + } + + public static void clearMessage(String key) { + INSTANCE.clearKey(key); + } + + public static void renderEngine(HudPhase phase, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + INSTANCE.renderEngineInternal(renderer, textRenderer); + } + + public static void renderPreview(Renderer2D renderer, + TextRenderer textRenderer, + float x, + float y, + float w, + float h) { + INSTANCE.renderPreviewInternal(renderer, textRenderer, x, y, w, h); + } + + private static ToastPosition toastSlideOffset(MessengerSide side, float anim, float baseScale) { + float offset = (1.0f - clamp01(anim)) * BASE_TOAST_SLIDE * baseScale; + return switch (side) { + case LEFT -> new ToastPosition(-offset, 0f); + case TOP -> new ToastPosition(0f, -offset); + case BOTTOM -> new ToastPosition(0f, offset); + default -> new ToastPosition(offset, 0f); + }; + } + + private static PreviewToast previewExample(long now) { + int index = (int) ((Math.max(0L, now) / PREVIEW_ROTATE_MS) % PREVIEW_EXAMPLES.length); + return PREVIEW_EXAMPLES[index]; + } + + private static long previewStart(long now) { + long safeNow = Math.max(0L, now); + return safeNow - safeNow % PREVIEW_ROTATE_MS; + } + + private static ToastMetrics measureToast(Toast toast, + TextRenderer lineRenderer, + TextRenderer iconRenderer, + float lineScale, + float iconScale, + float baseScale) { + boolean moduleToggle = toast.visual == ToastVisual.MODULE_TOGGLE; + String icon = iconFor(toast.type); + float iconW = moduleToggle ? BASE_TOGGLE_W * baseScale : measureWidth(iconRenderer, icon, iconScale); + float iconH = moduleToggle ? BASE_TOGGLE_H * baseScale : measureHeight(iconRenderer, iconScale); + float pipeW = measureWidth(lineRenderer, "|", lineScale); + float textW = measureWidth(lineRenderer, toast.line, lineScale); + if (PREVIEW_KEY.equals(toast.key)) { + textW = Math.max(textW, previewMaxTextWidth(lineRenderer, lineScale)); + } + float textH = measureHeight(lineRenderer, lineScale); + + float leftPad = BASE_LEFT_PAD * baseScale; + float rightPad = BASE_RIGHT_PAD * baseScale; + float iconPipeGap = (moduleToggle ? BASE_TOGGLE_PIPE_GAP : BASE_ICON_PIPE_GAP) * baseScale; + float pipeTextGap = BASE_PIPE_TEXT_GAP * baseScale; + + float boxW = leftPad + iconW + iconPipeGap + pipeW + pipeTextGap + textW + rightPad; + float boxH = Math.max(BASE_BOX_HEIGHT * baseScale, Math.max(textH, iconH) + (6.0f * baseScale)); + float radius = boxH * 0.5f; + float iconX = leftPad; + float iconY = (boxH - iconH) * 0.5f; + float pipeX = iconX + iconW + iconPipeGap; + float textX = pipeX + pipeW + pipeTextGap; + float textY = (boxH - textH) * 0.5f + (0.8f * baseScale); + + return new ToastMetrics(boxW, boxH, radius, textScale(lineScale), iconScale, iconX, iconY, iconW, iconH, pipeX, textX, textY); + } + + private static float previewMaxTextWidth(TextRenderer renderer, float scale) { + float max = 0f; + for (PreviewToast preview : PREVIEW_EXAMPLES) { + max = Math.max(max, measureWidth(renderer, preview.line(), scale)); + } + return max; + } + + private static float textScale(float value) { + return value; + } + + private static ToastPalette paletteFor() { + int progressTrack = 0x33FFFFFF; + int progressFill = 0xC4FFFFFF; + int bg = 0x52000000; + return new ToastPalette( + bg, + bg, + 0xFFF7FAFF, + progressTrack, + progressFill, + 0xFFFFFFFF + ); + } + + private static NotifyType resolveType(String text, Boolean enabled) { + if (enabled != null) return enabled ? NotifyType.YES : NotifyType.NO; + String lower = text.toLowerCase(); + if (lower.contains("warn") || lower.contains("warning") || lower.contains("cooldown")) { + return NotifyType.WARN; + } + if (lower.contains("error") || lower.contains("failed") + || lower.contains("fail") || lower.contains("denied") + || lower.contains("blocked") || lower.contains("disabled")) { + return NotifyType.NO; + } + return NotifyType.INFO; + } + + private static String iconFor(NotifyType type) { + return switch (type) { + case YES -> ICON_YES; + case NO -> ICON_NO; + case WARN -> ICON_WARN; + case INFO -> ICON_INFO; + }; + } + + private static int colorFor(NotifyType type) { + return switch (type) { + case YES -> 0xFF6DFF56; + case NO -> 0xFFFF5656; + case WARN -> 0xFFF5FF5E; + case INFO -> 0xFF4AA3FF; + }; + } + + private static int moduleToggleColor(float progress, float anim) { + Themes.Theme theme = Theme.theme(); + int off = theme != null ? HudRenderUtil.mixColor(theme.textMuted(), theme.textPrimary(), 0.18f) : 0xFFC6D2CD; + int on = theme != null ? theme.accent() : 0xFF5CC8E7; + int color = HudRenderUtil.mixColor(off, on, AnimationUtility.clamp01(progress)); + return applyAlpha(color, anim); + } + + private static String resolveLine(String text, Boolean enabled) { + if (enabled != null) { + return text + " - " + (enabled ? "enabled!" : "disabled!"); + } + return text; + } + + private static float easeOutBack(float t) { + float c1 = 1.70158f; + float c3 = c1 + 1f; + float p = t - 1f; + return 1f + c3 * p * p * p + c1 * p * p; + } + + private static float clamp01(float v) { + return Math.max(0f, Math.min(1f, v)); + } + + private static int applyAlpha(int argb, float alpha) { + int a = (int) (((argb >>> 24) & 0xFF) * alpha); + return (argb & 0x00FFFFFF) | ((a & 0xFF) << 24); + } + + private static float measureWidth(TextRenderer renderer, String text, float scale) { + if (renderer == null || text == null || text.isEmpty()) return 0f; + renderer.begin(scale, true, false); + float width = (float) renderer.getWidth(text, false); + renderer.end(); + return width; + } + + private static float measureHeight(TextRenderer renderer, float scale) { + if (renderer == null) return 0f; + renderer.begin(scale, true, false); + float height = (float) renderer.getHeight(false); + renderer.end(); + return height; + } + + private static int scaleAlpha(int argb, float factor) { + factor = AnimationUtility.clamp01(factor); + int a = (argb >>> 24) & 0xFF; + int scaledA = Math.round(a * factor); + return (argb & 0x00FFFFFF) | ((scaledA & 0xFF) << 24); + } + + private static int darken(int argb, float factor) { + factor = AnimationUtility.clamp(factor, 0f, 1f); + int a = (argb >>> 24) & 0xFF; + int r = (argb >>> 16) & 0xFF; + int g = (argb >>> 8) & 0xFF; + int b = argb & 0xFF; + + r = Math.round(r * factor); + g = Math.round(g * factor); + b = Math.round(b * factor); + + return ((a & 0xFF) << 24) + | ((r & 0xFF) << 16) + | ((g & 0xFF) << 8) + | (b & 0xFF); + } + + private static void applyColor(RenderColor out, int argb) { + out.a = (argb >>> 24) & 0xFF; + out.r = (argb >>> 16) & 0xFF; + out.g = (argb >>> 8) & 0xFF; + out.b = argb & 0xFF; + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(durationMs)); + defs.add(SettingDef.number(fadeMs)); + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.bool(soundEnabled)); + defs.add(SettingDef.mode(soundMode).visibleWhen(soundEnabled::get)); + defs.add(SettingDef.number(soundVolume).visibleWhen(soundEnabled::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + HudGlobalConfig hud = HudGlobalConfig.get(); + float scale = HudScale.scale(screenW, screenH); + if (hud != null) { + scale *= hud.getFontSize() / 18f; + } + scale *= getScale(); + + float margin = BASE_MARGIN * scale; + float width = 170f * scale; + float height = BASE_BOX_HEIGHT * scale; + // Position side is intentionally automatic now; the draggable position itself defines slide direction. + this.x = Math.max(margin, screenW - margin - width); + this.y = Math.max(margin, screenH - margin - BASE_BOTTOM_OFFSET * scale - height); + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public HudPhase getHudPhase() { + return HudPhase.AFTER_SUBTITLES; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + renderEngineInternal(renderer, textRenderer); + } + + private void renderEngineInternal(Renderer2D renderer, + TextRenderer textRenderer) { + if (RuntimeGate.isPanic()) return; + if (mc == null || mc.level == null || mc.player == null) return; + + HudGlobalConfig hud = HudGlobalConfig.get(); + if (hud == null) return; + + boolean preview = DraggableHudElementRegistry.isForceVisible() || ClientScreen.current() instanceof ChatScreen; + if (!enabledValue().get() && !preview) return; + if (hud.getHeaderMode() == HudGlobalConfig.HeaderMode.NEVER && !preview) return; + + int duration = Math.max(1, getDurationMs()); + int fade = Math.max(0, getFadeMs()); + long now = System.currentTimeMillis(); + + pruneExpiredToasts(now, duration); + + boolean hasRealToasts = !toasts.isEmpty(); + boolean sample = preview && !hasRealToasts; + List source = sample ? previewToasts(now) : toasts; + + if (source.isEmpty()) { + resetStackLayout(); + setBounds(x, y, 0f, 0f); + return; + } + + Window window = mc.getWindow(); + int fbw = window.getWidth(); + int fbh = window.getHeight(); + int screenW = Math.max(1, Math.round(HudScale.virtualWidth(fbw, fbh))); + int screenH = Math.max(1, Math.round(HudScale.virtualHeight(fbw, fbh))); + + float baseScale = HudScale.scale(screenW, screenH) * (hud.getFontSize() / 18f) * getScale(); + float gap = BASE_GAP * baseScale; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer lineRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, fallback); + TextRenderer iconRenderer = Fonts.renderer("Icons", FontInfo.Type.Regular, fallback); + if (lineRenderer == null) lineRenderer = fallback; + if (iconRenderer == null) iconRenderer = fallback; + float lineScale = baseScale * 0.95f; + float iconScale = baseScale * 1.08f; + + List draws = new ArrayList<>(); + float maxW = 0f; + float totalH = 0f; + + for (int i = source.size() - 1; i >= 0; i--) { + Toast toast = source.get(i); + float anim = sample ? 1.0f : toast.anim(now, duration, fade); + if (anim <= 0.001f) continue; + + ToastMetrics metrics = measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale); + float remaining = sample ? 0.45f : toast.remaining(now, duration); + draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f)); + maxW = Math.max(maxW, metrics.boxW); + totalH += metrics.boxH * anim; + if (i > 0) { + totalH += gap * anim; + } + } + + if (draws.isEmpty()) { + resetStackLayout(); + setBounds(x, y, 0f, 0f); + return; + } + + LayoutSize layout = resolveStackLayout(maxW, totalH, sample); + float layoutW = Math.max(maxW, layout.width()); + float layoutH = Math.max(totalH, layout.height()); + + MessengerSide side = getSide(); + float cursorY = y; + for (int i = 0; i < draws.size(); i++) { + ToastDraw draw = draws.get(i); + ToastMetrics metrics = draw.metrics; + float targetX = switch (side) { + case LEFT -> x; + case TOP, BOTTOM -> x + (layoutW - metrics.boxW) * 0.5f; + default -> x + (layoutW - metrics.boxW); + }; + float targetY = cursorY; + ToastPosition pos = resolveToastPosition(draw.toast, targetX, targetY, sample, baseScale); + ToastPosition slide = toastSlideOffset(side, draw.anim, baseScale); + draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, pos.x() + slide.x(), pos.y() + slide.y())); + cursorY += (metrics.boxH + gap) * draw.anim; + } + + setBounds(x, y, layoutW, Math.max(1f, layoutH)); + + drawGlassBlurBatch(draws, baseScale); + for (ToastDraw draw : draws) { + drawToastCard( + renderer, + draw.toast, + lineRenderer, + iconRenderer, + draw.x, + draw.y, + draw.metrics, + draw.remaining, + draw.anim, + baseScale + ); + } + } + + private void pruneExpiredToasts(long now, int duration) { + Iterator it = toasts.iterator(); + while (it.hasNext()) { + Toast t = it.next(); + if (t.isExpired(now, duration)) { + it.remove(); + } + } + } + + private LayoutSize resolveStackLayout(float targetW, float targetH, boolean sample) { + if (sample) { + resetStackLayout(); + return new LayoutSize(targetW, targetH); + } + + if (!stackLayoutReady) { + stackWidthAnim = targetW; + stackHeightAnim = targetH; + stackLayoutReady = true; + return new LayoutSize(targetW, targetH); + } + + float dt = AnimationUtility.deltaTime(); + stackWidthAnim = AnimationUtility.approach(stackWidthAnim, targetW, dt, LAYOUT_SMOOTH_SPEED); + stackHeightAnim = AnimationUtility.approach(stackHeightAnim, targetH, dt, LAYOUT_SMOOTH_SPEED); + stackWidthAnim = AnimationUtility.snap(stackWidthAnim, targetW, LAYOUT_SNAP_EPS); + stackHeightAnim = AnimationUtility.snap(stackHeightAnim, targetH, LAYOUT_SNAP_EPS); + return new LayoutSize(stackWidthAnim, stackHeightAnim); + } + + private ToastPosition resolveToastPosition(Toast toast, float targetX, float targetY, boolean sample, float baseScale) { + if (toast == null || sample) { + return new ToastPosition(targetX, targetY); + } + + if (!toast.layoutReady) { + toast.layoutX = targetX; + toast.layoutY = targetY; + toast.layoutReady = true; + return new ToastPosition(targetX, targetY); + } + + float dt = AnimationUtility.deltaTime(); + float eps = Math.max(0.05f, LAYOUT_SNAP_EPS * baseScale); + toast.layoutX = AnimationUtility.approach(toast.layoutX, targetX, dt, LAYOUT_SMOOTH_SPEED); + toast.layoutY = AnimationUtility.approach(toast.layoutY, targetY, dt, LAYOUT_SMOOTH_SPEED); + toast.layoutX = AnimationUtility.snap(toast.layoutX, targetX, eps); + toast.layoutY = AnimationUtility.snap(toast.layoutY, targetY, eps); + return new ToastPosition(toast.layoutX, toast.layoutY); + } + + private void resetStackLayout() { + stackLayoutReady = false; + stackWidthAnim = 0f; + stackHeightAnim = 0f; + } + + private List previewToasts(long now) { + return List.of(previewToast(now)); + } + + private Toast previewToast(long now) { + PreviewToast preview = previewExample(now); + long start = previewStart(now); + return new Toast(PREVIEW_KEY, preview.line(), preview.type(), start, preview.visual(), preview.enabled()); + } + + private void renderPreviewInternal(Renderer2D renderer, + TextRenderer textRenderer, + float originX, + float originY, + float width, + float height) { + if (renderer == null) return; + HudGlobalConfig hud = HudGlobalConfig.get(); + if (hud == null) return; + if (width <= 2f || height <= 2f) return; + + int screenW = Math.max(1, Math.round(width)); + int screenH = Math.max(1, Math.round(height)); + + float baseScale = HudScale.scale(screenW, screenH) * (hud.getFontSize() / 18f) * getScale(); + float margin = BASE_MARGIN * baseScale; + float gap = BASE_GAP * baseScale; + + MessengerSide side = getSide(); + float cursorY = margin; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer lineRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, fallback); + TextRenderer iconRenderer = Fonts.renderer("Icons", FontInfo.Type.Regular, fallback); + if (lineRenderer == null) lineRenderer = fallback; + if (iconRenderer == null) iconRenderer = fallback; + float lineScale = baseScale * 0.95f; + float iconScale = baseScale * 1.08f; + + long now = System.currentTimeMillis(); + int duration = Math.max(1, getDurationMs()); + int fade = Math.max(0, getFadeMs()); + pruneExpiredToasts(now, duration); + + boolean sampleMode = toasts.isEmpty(); + List source = sampleMode ? previewToasts(now) : toasts; + + List draws = new ArrayList<>(); + float maxW = 0f; + for (int i = source.size() - 1; i >= 0; i--) { + Toast toast = source.get(i); + float anim = sampleMode ? 1.0f : toast.anim(now, duration, fade); + if (anim <= 0.001f) continue; + + ToastMetrics metrics = measureToast(toast, lineRenderer, iconRenderer, lineScale, iconScale, baseScale); + float remaining = sampleMode ? 0.45f : toast.remaining(now, duration); + maxW = Math.max(maxW, metrics.boxW); + draws.add(new ToastDraw(toast, metrics, remaining, anim, 0f, 0f)); + } + + for (int i = 0; i < draws.size(); i++) { + ToastDraw draw = draws.get(i); + ToastMetrics metrics = draw.metrics; + float targetX = switch (side) { + case LEFT -> originX + margin; + case TOP, BOTTOM -> originX + (screenW - metrics.boxW) * 0.5f; + default -> originX + screenW - margin - metrics.boxW; + }; + float targetY = originY + cursorY; + ToastPosition slide = toastSlideOffset(side, draw.anim, baseScale); + draws.set(i, new ToastDraw(draw.toast, metrics, draw.remaining, draw.anim, targetX + slide.x(), targetY + slide.y())); + cursorY += (metrics.boxH + gap) * draw.anim; + } + + drawGlassBlurBatch(draws, baseScale); + for (ToastDraw draw : draws) { + drawToastCard( + renderer, + draw.toast, + lineRenderer, + iconRenderer, + draw.x, + draw.y, + draw.metrics, + draw.remaining, + draw.anim, + baseScale + ); + } + } + + @Override + public void onModuleStateChanged(String name, boolean enabled) { + Module module = ModuleManager.get(name); + // HudNotifier module-state info logging disabled; keep only warnings/errors here. + if (module == null) { + // HudNotifier module-state info logging disabled; keep only warnings/errors here. + return; + } + if (!module.isShownInModuleList()) { + // HudNotifier module-state info logging disabled; keep only warnings/errors here. + return; + } + enqueueModuleState(module.getDisplayName(), enabled); + } + + private void enqueueModuleState(String text, boolean enabled) { + enqueueInternal(null, text, enabled, enabled ? NotifyType.YES : NotifyType.NO, ToastVisual.MODULE_TOGGLE); + } + + private void enqueue(String key, String text, Boolean enabled, NotifyType forcedType) { + NotifyType type = forcedType != null ? forcedType : resolveType(text, enabled); + enqueueInternal(key, text, enabled, type, ToastVisual.STANDARD); + } + + private void enqueueInternal(String key, String text, Boolean enabled, NotifyType type, ToastVisual visual) { + if (text == null || text.isBlank()) return; + + HudGlobalConfig hud = HudGlobalConfig.get(); + if (hud == null) return; + if (!enabledValue().get()) return; + if (hud.getHeaderMode() == HudGlobalConfig.HeaderMode.NEVER) return; + + String line = resolveLine(text, enabled); + long now = System.currentTimeMillis(); + + if (key != null) { + for (Toast toast : toasts) { + if (key.equals(toast.key)) { + toast.line = line; + toast.type = type; + toast.visual = visual == null ? ToastVisual.STANDARD : visual; + toast.stateEnabled = enabled; + toast.startMs = now; + return; + } + } + } + + toasts.add(new Toast(key, line, type, now, visual, enabled)); + if (toasts.size() > MAX_TOASTS) { + toasts.remove(0); + } + + if (enabled != null) { + playSound(enabled); + } + } + + private void drawToastCard(Renderer2D renderer, + Toast toast, + TextRenderer lineRenderer, + TextRenderer iconRenderer, + float x, + float y, + ToastMetrics metrics, + float remaining, + float anim, + float baseScale) { + ToastPalette palette = paletteFor(); + + int glassTint = applyAlpha(palette.glassTint, anim); + int bgLeft = applyAlpha(palette.bgLeft, anim); + int bgRight = applyAlpha(palette.bgRight, anim); + long now = System.currentTimeMillis(); + float wave = 0.5f + 0.5f * (float) Math.sin(now / 1000.0f * TEXT_ANIM_SPEED); + float darkenFactor = AnimationUtility.lerp(TEXT_DARKEN_MIN, TEXT_DARKEN_MAX, wave); + + int text = scaleAlpha(applyAlpha(palette.text, anim), TEXT_ALPHA_FACTOR); + text = darken(text, darkenFactor); + + int progressTrack = applyAlpha(palette.progressTrack, anim); + int progress = applyAlpha(palette.progressFill, anim); + int accent = applyAlpha(colorFor(toast.type), anim); + + int pipe = scaleAlpha(applyAlpha(HudRenderUtil.mixColor(palette.text, palette.progressTrack, 0.55f), anim), PIPE_ALPHA_FACTOR); + pipe = darken(pipe, darkenFactor * 0.96f); + float sideBottomR = Math.max(1f, metrics.boxH * 0.5f); + float sideTopR = 0f; + + drawGlassPill( + renderer, + x, + y, + metrics.boxW, + metrics.boxH, + sideTopR, + sideTopR, + sideBottomR, + sideBottomR, + glassTint, + anim + ); + + int bgMixed = HudRenderUtil.mixColor(bgLeft, bgRight, 0.5f); + int bgA = (bgMixed >>> 24) & 0xFF; + int bgSolid = HudRenderUtil.glassBackground(anim); + renderer.roundedRectCornersQuad( + x, + y, + metrics.boxW, + metrics.boxH, + sideTopR, + sideTopR, + sideBottomR, + sideBottomR, + 1.0f, + bgSolid, + bgSolid, + bgSolid, + bgSolid + ); + + float progressInsetX = 0.45f * baseScale; + float progressInsetY = 0.08f * baseScale; + float progressH = Math.max(1f, BASE_PROGRESS_H * baseScale); + float progressY = y + progressInsetY; + float progressX = x + progressInsetX; + float progressAreaW = Math.max(0f, metrics.boxW - progressInsetX * 2f); + float progressW = Math.max(0f, progressAreaW * clamp01(remaining)); + float progressTopR = 0f; + float progressBottomR = 0.02f * baseScale; + renderer.roundedRectCornersQuad( + progressX, + progressY, + progressAreaW, + progressH, + progressTopR, + progressTopR, + progressBottomR, + progressBottomR, + 1.0f, + progressTrack, + progressTrack, + progressTrack, + progressTrack + ); + if (progressW > 0f) { + renderer.roundedRectCornersQuad( + progressX, + progressY, + progressW, + progressH, + progressTopR, + progressTopR, + progressBottomR, + progressBottomR, + 1.0f, + progress, + progress, + progress, + progress + ); + } + + if (toast.visual == ToastVisual.MODULE_TOGGLE) { + drawModuleToggle(renderer, toast, x + metrics.iconX, y + metrics.iconY, metrics.iconW, metrics.iconH, accent, anim, now); + } else { + iconRenderer.begin(metrics.iconScale, false, false); + applyColor(colorTmp, accent); + iconRenderer.render(iconFor(toast.type), x + metrics.iconX, y + metrics.iconY, colorTmp, false); + iconRenderer.end(); + } + + lineRenderer.begin(metrics.textScale, false, false); + applyColor(colorTmp, pipe); + lineRenderer.render("|", x + metrics.pipeX, y + metrics.textY, colorTmp, false); + applyColor(colorTmp, text); + lineRenderer.render(toast.line, x + metrics.textX, y + metrics.textY, colorTmp, false); + lineRenderer.end(); + } + + private void drawModuleToggle(Renderer2D renderer, + Toast toast, + float x, + float y, + float w, + float h, + int fallbackAccent, + float anim, + long now) { + if (renderer == null || w <= 0.1f || h <= 0.1f) return; + + float progress = toast.toggleProgress(now); + int tint = moduleToggleColor(progress, anim); + if (((tint >>> 24) & 0xFF) <= 0) { + tint = fallbackAccent; + } + + float svgY = y + (h - w) * 0.5f; + SvgRenderOptions options = SvgRenderOptions.overrideColor(tint); + if (progress <= 0.001f) { + renderer.svg("toggle-left", x, svgY, w, w, options); + return; + } + if (progress >= 0.999f) { + renderer.svg("toggle-right", x, svgY, w, w, options); + return; + } + + renderer.svg("toggle-track", x, svgY, w, w, options); + + float thumbTravel = w * (3f / 24f); + float thumbX = x + AnimationUtility.lerp(-thumbTravel, thumbTravel, progress); + renderer.svg("toggle-thumb", thumbX, svgY, w, w, options); + } + + private void drawGlassBlurBatch(List draws, float baseScale) { + if (draws == null || draws.isEmpty()) return; + HudGlobalConfig hud = HudGlobalConfig.get(); + if (hud == null) return; + + Renderer2D.COLOR.blurComposite(composite -> { + for (ToastDraw draw : draws) { + if (draw == null || draw.metrics == null || draw.anim <= 0.001f) continue; + float a = clamp01(draw.anim); + float sideBottomR = Math.max(1f, draw.metrics.boxH * 0.5f); + composite.roundedRectCorners( + draw.x, + draw.y, + draw.metrics.boxW, + draw.metrics.boxH, + 0f, + 0f, + sideBottomR, + sideBottomR, + hud.getBlurRadius(), + 1.0f, + (GLASS_BLUR_ALPHA / 255.0f) * a, + 0xFFFFFF + ); + } + }); + } + + private void drawGlassPill(Renderer2D renderer, + float x, + float y, + float w, + float h, + float radiusTL, + float radiusTR, + float radiusBR, + float radiusBL, + int tintArgb, + float anim) { + if (renderer == null) return; + float a = clamp01(anim); + if (a <= 0.001f) return; + + float glassAlpha = (GLASS_PASS_ALPHA / 255.0f) * a; + float glassScale = Math.max(0.50f, Math.min(1.25f, h / 24.0f)); + HudRenderUtil.drawLiquidGlassCorners( + x, + y, + w, + h, + radiusTL, + radiusTR, + radiusBR, + radiusBL, + glassScale, + false, + glassAlpha, + tintArgb + ); + } + + private int getDurationMs() { + return durationMs.get(); + } + + private int getFadeMs() { + return fadeMs.get(); + } + + private MessengerSide getSide() { + Window window = mc == null ? null : mc.getWindow(); + if (window == null) return MessengerSide.RIGHT; + int fbw = Math.max(1, window.getWidth()); + int fbh = Math.max(1, window.getHeight()); + float screenW = HudScale.virtualWidth(fbw, fbh); + float screenH = HudScale.virtualHeight(fbw, fbh); + float cx = x + Math.max(1f, getWidth()) * 0.5f; + float cy = y + Math.max(1f, getHeight()) * 0.5f; + if (cy < screenH * 0.18f) return MessengerSide.TOP; + if (cy > screenH * 0.82f) return MessengerSide.BOTTOM; + return cx < screenW * 0.50f ? MessengerSide.LEFT : MessengerSide.RIGHT; + } + + private float getScale() { + return scaleValue.get().floatValue(); + } + + private float getSoundVolume() { + return soundVolume.get().floatValue(); + } + + private void playSound(boolean enabled) { + if (ModuleManager.isToggleSoundSuppressed()) return; + if (!soundEnabled.get()) return; + Identifier snd = resolveSound(enabled, soundMode.get()); + if (snd == null) return; + double vol = Math.min(1.0, getSoundVolume()); + CustomSoundEngine.get().play(snd, vol, 1.0, false, true); + } + + private void clearKey(String key) { + if (key == null) return; + toasts.removeIf(t -> key.equals(t.key)); + } + + private Identifier resolveSound(boolean enabled, String mode) { + String base = switch (mode) { + case "2" -> "enable/enable2"; + case "3" -> "enable/enable3"; + default -> "enable/enable1"; + }; + if (!enabled) { + base = base.replace("enable/", "disable/").replace("enable", "disable"); + } + return Identifier.fromNamespaceAndPath("lovisual", "sounds/" + base + ".wav"); + } + + private enum MessengerSide { + RIGHT, + LEFT, + TOP, + BOTTOM; + + static MessengerSide from(String name) { + if (name == null) return RIGHT; + return switch (name.toLowerCase()) { + case "left" -> LEFT; + case "top" -> TOP; + case "bottom" -> BOTTOM; + default -> RIGHT; + }; + } + } + + public enum NotifyType { + YES, + NO, + WARN, + INFO + } + + private enum ToastVisual { + STANDARD, + MODULE_TOGGLE + } + + private static final class Toast { + private final String key; + private String line; + private NotifyType type; + private ToastVisual visual; + private Boolean stateEnabled; + private long startMs; + private boolean layoutReady; + private float layoutX; + private float layoutY; + + private Toast(String key, String line, NotifyType type, long startMs) { + this(key, line, type, startMs, ToastVisual.STANDARD, null); + } + + private Toast(String key, String line, NotifyType type, long startMs, ToastVisual visual, Boolean stateEnabled) { + this.key = key; + this.line = line; + this.type = type; + this.visual = visual == null ? ToastVisual.STANDARD : visual; + this.stateEnabled = stateEnabled; + this.startMs = startMs; + } + + boolean isExpired(long now, int duration) { + return now - startMs > duration; + } + + float remaining(long now, int duration) { + if (duration <= 0) return 0f; + long age = Math.max(0L, now - startMs); + return 1f - clamp01((float) age / (float) duration); + } + + float anim(long now, int duration, int fade) { + long age = Math.max(0L, now - startMs); + if (age >= duration) return 0f; + + float in = clamp01((float) age / (float) ENTER_MS); + float enter = easeOutBack(in); + + int outMs = Math.max(1, fade > 0 ? fade : DEFAULT_EXIT_MS); + float out = 1f; + if (age > duration - outMs) { + out = clamp01((float) (duration - age) / (float) outMs); + } + + return clamp01(enter * out); + } + + float toggleProgress(long now) { + boolean enabled = stateEnabled != null ? stateEnabled : type == NotifyType.YES; + long age = Math.max(0L, now - startMs); + float t = AnimationUtility.easeInOutCubic((float) age / (float) TOGGLE_TRANSITION_MS); + return enabled ? t : 1f - t; + } + } + + private record PreviewToast(String line, NotifyType type, ToastVisual visual, Boolean enabled) { + } + + private record LayoutSize(float width, float height) { + } + + private record ToastPosition(float x, float y) { + } + + private record ToastDraw(Toast toast, + ToastMetrics metrics, + float remaining, + float anim, + float x, + float y) { + } + + private record ToastMetrics(float boxW, + float boxH, + float radius, + float textScale, + float iconScale, + float iconX, + float iconY, + float iconW, + float iconH, + float pipeX, + float textX, + float textY) { + } + + private record ToastPalette(int bgLeft, + int bgRight, + int text, + int progressTrack, + int progressFill, + int glassTint) { + } + @Override + public void collectRenderPrewarm(RenderPrewarmCollectEvent event) { + super.collectRenderPrewarm(event); + event.svg("toggle-left") + .svg("toggle-right") + .svg("toggle-track") + .svg("toggle-thumb"); + } + +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudPanelLayoutModes.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudPanelLayoutModes.java new file mode 100644 index 0000000..a947216 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/HudPanelLayoutModes.java @@ -0,0 +1,34 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.ModeValue; +import dev.loki.lovisual.config.values.NumberValue; + +enum HudPanelLayoutModes { + ; + static final String SPLIT_HEADER = "Split Header"; + static final String UNIFIED_DIVIDER = "Unified Divider"; + + static final double SCALE_DEFAULT = 1.0; + static final double SCALE_MIN = 1.0; + static final double SCALE_MAX = 1.4; + static final float SCALE_BASE_MULTIPLIER = 2.0f; + + static float effectiveScale(NumberValue value) { + if (value == null || value.get() == null) { + return SCALE_BASE_MULTIPLIER; + } + return value.get().floatValue() * SCALE_BASE_MULTIPLIER; + } + + static String current(ModeValue value) { + String mode = value != null ? value.get() : null; + return UNIFIED_DIVIDER.equals(mode) ? UNIFIED_DIVIDER : SPLIT_HEADER; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Inventory.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Inventory.java new file mode 100644 index 0000000..235139a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Inventory.java @@ -0,0 +1,465 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.util.Util; +import net.minecraft.world.item.ItemStack; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.input.KeyManager; + +import java.util.ArrayList; +import java.util.List; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 220) +public final class Inventory extends DraggableHudElement { + + { + defaultLayout(1255.352f, 658.36005f); + } + + + private static final int COLS = 9; + private static final int ROWS = 3; + private static final int FIRST_INV_SLOT = 9; + private static final int LAST_INV_SLOT = 36; + + private static final float BASE_WIDTH = 123.0f; + private static final float BASE_HEIGHT = 62.0f; + private static final float HEADER_HEIGHT = 15.5f; + private static final float BODY_Y_OFFSET = 18.4f; + private static final float BODY_HEIGHT = 45.0f; + private static final float PANEL_RADIUS = 4.0f; + private static final float PANEL_SOFTNESS = 1.0f; + private static final float PANEL_STROKE = 0.55f; + private static final float HEADER_DIVIDER_X = 18.0f; + private static final float HEADER_DIVIDER_Y = 5.0f; + private static final float HEADER_DIVIDER_H = 6.0f; + private static final float TITLE_ICON_X = 4.5f; + private static final float TITLE_ICON_Y = 6.0f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float TITLE_TEXT_Y = 6.5f; + private static final float COUNT_LABEL_OFFSET = 21.0f; + private static final float COUNT_VALUE_OFFSET = 2.0f; + private static final float GRID_START_X = 4.0f; + private static final float GRID_START_Y = 22.0f; + private static final float GRID_STEP = 13.0f; + private static final float GRID_LINE_OFFSET_X = 11.0f; + private static final float GRID_LINE_OFFSET_Y = 10.0f; + private static final float GRID_LINE_LENGTH = 9.0f; + private static final float GRID_LINE_THICKNESS = 0.5f; + private static final float ITEM_RENDER_SCALE = 0.5f; + + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedInventoryHudPanel scriptedPanel = new ScriptedInventoryHudPanel(); + + private final KeyBindValue toggleBind = + new KeyBindValue("toggle_bind", "I"); + private final NumberValue scaleValue = + new NumberValue<>("inventory_scale", 1.32, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + private final ModeValue layoutMode = + new ModeValue("inventory_layout", "Unified Divider", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + private final ModeValue colorMode = + new ModeValue("inventory_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("inventory_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final RGBAColorValue bg = + new RGBAColorValue("inventory_bg", "#F7343434"); + private final RGBAColorValue bg2 = + new RGBAColorValue("inventory_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("inventory_bg_alpha", 194, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("inventory_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("inventory_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("inventory_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("inventory_blur_alpha", 255, 0, 255); + private final BooleanValue headerIconPulse = + new BooleanValue("inventory_header_icon_pulse", true); + private final NumberValue headerIconPulseSpeed = + new NumberValue<>("inventory_header_icon_pulse_speed", 15, 1, 60); + private final NumberValue headerIconPulseIntensity = + new NumberValue<>("inventory_header_icon_pulse_intensity", 100, 0, 100); + + private final List pendingItemTasks = new ArrayList<>(COLS * ROWS); + + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiDivider; + private int uiGridDivider; + private int uiBlurTint; + private boolean layoutReady; + private boolean hitboxActive; + private boolean displayVisible = true; + private boolean toggleArmed = true; + private boolean toggleInit = false; + + public Inventory() { + super("inventory", "Inventory", true); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.bind(toggleBind, BindMode.PRESS)); + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(headerIconPulse)); + defs.add(SettingDef.number(headerIconPulseSpeed).visibleWhen(headerIconPulse::get)); + defs.add(SettingDef.number(headerIconPulseIntensity).visibleWhen(headerIconPulse::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 385.0f; + this.y = 40.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void onTick() { + if (!toggleInit) { + if (mc == null || mc.getWindow() == null) { + return; + } + initToggleState(); + toggleInit = true; + } + + String combo = toggleBind.get(); + if (combo == null || combo.isBlank() || "NONE".equalsIgnoreCase(combo)) { + return; + } + if (mc == null || mc.getWindow() == null) { + return; + } + + boolean pressed = ClientScreen.current() == null && KeyManager.isComboHeldAllowScreen(combo); + if (!pressed) { + toggleArmed = true; + return; + } + if (toggleArmed) { + displayVisible = !displayVisible; + toggleArmed = false; + } + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + layoutReady = false; + hitboxActive = false; + pendingItemTasks.clear(); + + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !forceVisible)) { + width = 0.0f; + height = 0.0f; + return; + } + + if (!isEnabled() && !forceVisible) { + width = 0.0f; + height = 0.0f; + return; + } + + boolean chatPreview = ClientScreen.current() instanceof ChatScreen; + List stacks = collectStacks(); + boolean hasAnyItems = stacks.stream().anyMatch(stack -> stack != null && !stack.isEmpty()); + boolean displayTargetVisible = displayVisible; + boolean showWidget = forceVisible || (displayTargetVisible && (chatPreview || hasAnyItems)); + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + if (!showWidget && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + TextRenderer headerIconRenderer = Fonts.renderer("IconsNur", FontInfo.Type.Regular, TextRenderer.get()); + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer countRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerIconRenderer == null) headerIconRenderer = textRenderer; + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (countRenderer == null) countRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(scaleValue); + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + long itemCount = 0L; + for (ItemStack stack : stacks) { + if (stack != null && !stack.isEmpty()) { + itemCount += stack.getCount(); + } + } + + countRenderer.begin(fontScale * 0.92f, false, false); + String itemCountText = Long.toString(Math.max(0L, itemCount)); + float countLabelWidth = (float) countRenderer.getWidth("Items:", false); + float countValueWidth = (float) countRenderer.getWidth(itemCountText, false); + float countTextHeight = (float) countRenderer.getHeight(false); + countRenderer.end(); + + headerIconRenderer.begin(fontScale, false, false); + headerTextRenderer.begin(fontScale, false, false); + float headerIconH = (float) headerIconRenderer.getHeight(false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Inventory", false); + headerTextRenderer.end(); + headerIconRenderer.end(); + + float targetWidth = BASE_WIDTH * baseScale; + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + countLabelWidth + countValueWidth + + ((COUNT_LABEL_OFFSET + 12.0f) * baseScale); + targetWidth = Math.max(targetWidth, headerWidth); + float targetHeight = BASE_HEIGHT * baseScale; + + displayWidth = HudRenderUtil.animateDimension(displayWidth, targetWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderIconH = headerIconH * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawCountTextHeight = countTextHeight * drawScale; + float drawCountLabelWidth = countLabelWidth * drawScale; + float drawCountValueWidth = countValueWidth * drawScale; + hitboxActive = forceVisible || showWidget; + + int resolvedHeaderIconColor = uiCounter; + if (headerIconPulse.get()) { + int pulsedHeaderIconColor = HudTextEffects.animatedColor( + uiCounter, + HudTextEffects.Effect.PULSE, + headerIconPulseSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + resolvedHeaderIconColor = HudRenderUtil.mixColor( + uiCounter, + pulsedHeaderIconColor, + headerIconPulseIntensity.get() / 100.0f + ); + } + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedInventoryHudPanel.Panel( + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderIconH, + drawHeaderTextH, + drawCountTextHeight, + drawCountLabelWidth, + drawCountValueWidth, + itemCount, + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + resolvedHeaderIconColor, + uiGridDivider, + HudPanelLayoutModes.current(layoutMode), + new ScriptedListHudPanel.Palette(uiHeaderLeft, uiHeaderRight, uiBodyLeft, uiBodyRight, + uiOutline, uiText, uiMuted, uiCounter, uiTitleText, uiDivider, uiBlurTint), + ScriptedInventoryHudPanel.cells(stacks) + ) + ); + } + + @Override + public void renderEngineForeground(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (!layoutReady || ctx == null || pendingItemTasks.isEmpty()) { + pendingItemTasks.clear(); + return; + } + for (QueuedItemIcon task : pendingItemTasks) { + // Inventory is rendered in UNSCALED_LOGICAL space, so item() must receive + // logical coordinates directly. itemUnscaled() would shift icons out of slots. + renderer.item(task.stack(), task.x(), task.y(), task.scale(), task.seed(), Renderer2D.ITEM_OVERLAY_ALL, null); + } + pendingItemTasks.clear(); + } + + @Override + public boolean contains(float mx, float my) { + if (!hitboxActive && !DraggableHudElementRegistry.isForceVisible()) { + return false; + } + return super.contains(mx, my); + } + + private List collectStacks() { + List stacks = new ArrayList<>(LAST_INV_SLOT - FIRST_INV_SLOT); + if (mc == null || mc.player == null) { + return stacks; + } + for (int slot = FIRST_INV_SLOT; slot < LAST_INV_SLOT; slot++) { + stacks.add(mc.player.getInventory().getItem(slot)); + } + return stacks; + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiGridDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.28f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + return; + } + + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiGridDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.20f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private void initToggleState() { + toggleArmed = true; + String combo = toggleBind.get(); + if (combo != null && !combo.isBlank() && !"NONE".equalsIgnoreCase(combo) + && mc != null && ClientScreen.current() == null + && KeyManager.isComboHeldAllowScreen(combo)) { + toggleArmed = false; + } + } + + private record QueuedItemIcon(ItemStack stack, float x, float y, float scale, int seed) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Itemizer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Itemizer.java new file mode 100644 index 0000000..cb60894 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Itemizer.java @@ -0,0 +1,699 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.BooleanValue; +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.features.gui.hud.HudAnchorX; +import dev.loki.lovisual.features.gui.hud.HudAnchorY; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.ColorMath; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ClipFunction; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@HudElementRegister(order = 200) +public final class Itemizer extends DraggableHudElement { + private static final String LAYOUT_ID = "combatant:api/hud/draggable/itemizer"; + private static final float BASE_ICON_CARD = 24.0f; + private static final float BASE_COMPACT_W = 72.0f; + private static final float BASE_COMPACT_H = 24.0f; + private static final float BASE_GAP = 4.0f; + private static final float DEFAULT_DURATION = 1.15f; + private static final int MAX_INTERNAL_ENTRIES = 8; + + public static Itemizer INSTANCE; + + private final Minecraft mc = Minecraft.getInstance(); + private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID); + private final CachedUiScriptRuntime runtime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); + private final LinkedHashMap entries = new LinkedHashMap<>(); + private final List itemTasks = new ArrayList<>(5); + + private final NumberValue scale = + num("itemizer_scale", "scale", 1.0, 0.5, 3.0); + private final EnumValue displayMode = + enumSetting("itemizer_display_mode", "display_mode", ItemizerDisplayMode.ICONS, ItemizerDisplayMode.values()); + private final EnumValue direction = + enumSetting("itemizer_direction", "direction", ItemizerDirection.HORIZONTAL, ItemizerDirection.values()); + private final NumberValue duration = + num("itemizer_duration", "duration", (double) DEFAULT_DURATION, 0.25, 3.0); + private final NumberValue maxEntries = + num("itemizer_max_entries", "max_entries", 3, 1, 5); + private final BooleanValue showAutoEat = + bool("itemizer_auto_eat", "auto_eat", true); + private final BooleanValue showAutoTotem = + bool("itemizer_auto_totem", "auto_totem", true); + private final BooleanValue showElytraSwap = + bool("itemizer_elytra_swap", "elytra_swap", true); + private final BooleanValue blur = + bool("itemizer_blur", "blur", true); + private final NumberValue bgAlpha = + num("itemizer_bg_alpha", "bg_alpha", 168, 0, 255); + + private float visibilityAnim; + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private boolean foregroundReady; + + public Itemizer() { + super("itemizer", "Itemizer", true); + INSTANCE = this; + defaultLayout(-16.0f, 48.0f, "CENTER", "CENTER"); + } + + public static void showAutoEat(ItemStack stack) { + show(ItemizerEvent.AUTO_EAT, stack, "Eat"); + } + + public static void showAutoTotem(ItemStack stack) { + show(ItemizerEvent.AUTO_TOTEM, stack, "Totem"); + } + + public static void showElytraSwap(ItemStack stack) { + show(ItemizerEvent.ELYTRA_SWAP, stack, "Swap"); + } + + public static void hideElytraSwap() { + hide(ItemizerEvent.ELYTRA_SWAP); + } + + private static void show(ItemizerEvent event, ItemStack stack, String label) { + Itemizer itemizer = INSTANCE; + if (itemizer == null || event == null || stack == null || stack.isEmpty()) return; + itemizer.push(event, stack, label); + } + + private static void hide(ItemizerEvent event) { + Itemizer itemizer = INSTANCE; + if (itemizer == null || event == null) return; + itemizer.entries.remove(event); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + float w = baseEntryWidth() * scale.get().floatValue(); + float h = baseEntryHeight() * scale.get().floatValue(); + this.x = (screenW - w) * 0.5f; + this.y = screenH * 0.5f + 48.0f; + setAnchors(HudAnchorX.FREE, HudAnchorY.FREE, this.x, this.y); + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public HudPhase getHudPhase() { + return HudPhase.LAST; + } + + @Override + public int getRenderOrder() { + return 82; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + foregroundReady = false; + itemTasks.clear(); + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (!isEnabled() && !forceVisible) { + width = 0.0f; + height = 0.0f; + return; + } + + float dt = AnimationUtility.deltaTime(); + List visible = forceVisible && entries.isEmpty() + ? previewEntries() + : updateEntries(dt); + boolean showWidget = !visible.isEmpty(); + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + if (!showWidget && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float baseScale = scale.get().floatValue(); + int count = Math.max(1, visible.size()); + float entryW = baseEntryWidth() * baseScale; + float entryH = baseEntryHeight() * baseScale; + float gap = BASE_GAP * baseScale; + float targetWidth = direction.get() == ItemizerDirection.HORIZONTAL + ? entryW * count + gap * Math.max(0, count - 1) + : entryW; + float targetHeight = direction.get() == ItemizerDirection.HORIZONTAL + ? entryH + : entryH * count + gap * Math.max(0, count - 1); + + displayWidth = HudRenderUtil.animateDimension(displayWidth, targetWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + float drawScale = HudRenderUtil.visibilityScale(visibilityAnim); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) return; + + float drawX = x + (displayWidth - drawWidth) * 0.5f; + float drawY = y + (displayHeight - drawHeight) * 0.5f; + float drawBaseScale = baseScale * drawScale; + drawCards(renderer, visible, drawX, drawY, drawBaseScale, AnimationUtility.clamp01(visibilityAnim)); + + UiScriptModule module = ensureModule(); + if (module == null) return; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + LinkedHashMap props = props(visible, drawWidth, drawHeight, drawBaseScale, drawScale); + long treeSignature = signature(props); + long layoutSignature = 0xcbf29ce484222325L; + layoutSignature = CachedUiScriptRuntime.mix(layoutSignature, drawX); + layoutSignature = CachedUiScriptRuntime.mix(layoutSignature, drawY); + layoutSignature = CachedUiScriptRuntime.mix(layoutSignature, drawWidth); + layoutSignature = CachedUiScriptRuntime.mix(layoutSignature, drawHeight); + + UiRuntime baked = runtime.bake( + moduleHandle, + module, + "itemizer", + treeSignature, + layoutSignature, + drawWidth, + drawHeight, + fallback, + drawX, + drawY, + drawWidth, + drawHeight, + () -> props + ); + if (baked == null) return; + baked.render(new UiRenderContext(renderer, fallback, ctx, tickDelta, UiProjectionMode.CURRENT)); + foregroundReady = true; + } + + @Override + public void renderEngineForeground(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (!foregroundReady || itemTasks.isEmpty()) { + itemTasks.clear(); + return; + } + int seed = 0; + for (ItemRenderTask task : itemTasks) { + drawItem(renderer, task.stack(), task.x(), task.y(), task.scale(), seed++, task.overlayFlags(), task.alpha()); + } + itemTasks.clear(); + foregroundReady = false; + } + + private void push(ItemizerEvent event, ItemStack stack, String label) { + if (!accepts(event)) return; + Entry entry = entries.remove(event); + if (entry == null) { + entry = new Entry(event); + } + entry.stack = stack.copy(); + entry.label = label != null && !label.isBlank() ? label : event.fallbackLabel; + entry.life = Math.max(0.1f, duration.get().floatValue()); + entry.totalLife = entry.life; + entry.punch = 1.0f; + entries.put(event, entry); + trimInternalEntries(); + } + + private boolean accepts(ItemizerEvent event) { + return switch (event) { + case AUTO_EAT -> showAutoEat.get(); + case AUTO_TOTEM -> showAutoTotem.get(); + case ELYTRA_SWAP -> showElytraSwap.get(); + }; + } + + private void trimInternalEntries() { + while (entries.size() > MAX_INTERNAL_ENTRIES) { + ItemizerEvent first = entries.keySet().iterator().next(); + entries.remove(first); + } + } + + private List updateEntries(float dt) { + List remove = new ArrayList<>(); + List out = new ArrayList<>(); + for (Map.Entry mapEntry : entries.entrySet()) { + Entry entry = mapEntry.getValue(); + entry.life = Math.max(0.0f, entry.life - dt); + entry.punch = AnimationUtility.approach(entry.punch, 0.0f, dt, 8.0f); + boolean alive = entry.life > 0.0f; + entry.anim = AnimationUtility.approach(entry.anim, alive ? 1.0f : 0.0f, dt, 13.0f); + entry.anim = AnimationUtility.snap(entry.anim, alive ? 1.0f : 0.0f, 0.002f); + if (!alive && entry.anim <= 0.0f) { + remove.add(mapEntry.getKey()); + continue; + } + if (out.size() < maxEntries.get()) { + out.add(entry); + } + } + for (ItemizerEvent event : remove) { + entries.remove(event); + } + return out; + } + + private List previewEntries() { + Entry eat = new Entry(ItemizerEvent.AUTO_EAT); + eat.stack = new ItemStack(Items.COOKED_BEEF); + eat.label = "Eat"; + eat.life = 1.0f; + eat.totalLife = 1.0f; + eat.anim = 1.0f; + + Entry totem = new Entry(ItemizerEvent.AUTO_TOTEM); + totem.stack = new ItemStack(Items.TOTEM_OF_UNDYING); + totem.label = "Totem"; + totem.life = 1.0f; + totem.totalLife = 1.0f; + totem.anim = 1.0f; + + Entry swap = new Entry(ItemizerEvent.ELYTRA_SWAP); + swap.stack = new ItemStack(Items.ELYTRA); + swap.label = "Swap"; + swap.life = 1.0f; + swap.totalLife = 1.0f; + swap.anim = 1.0f; + + List list = new ArrayList<>(); + list.add(eat); + list.add(totem); + list.add(swap); + return list.subList(0, Math.min(maxEntries.get(), list.size())); + } + + private UiScriptModule ensureModule() { + if (mc == null || mc.getResourceManager() == null) return null; + HudScriptLayouts.pollReloadCombo(mc); + if (moduleHandle.consumeChanged()) { + runtime.reset(); + } + if (!moduleHandle.ensureLoaded(mc.getResourceManager())) { + HudScriptLayouts.reportLoadError(moduleHandle); + return null; + } + moduleHandle.consumeChanged(); + return moduleHandle.module(); + } + + private LinkedHashMap props(List visible, + float width, + float height, + float baseScale, + float drawScale) { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("id", "itemizer"); + out.put("width", width); + out.put("height", height); + out.put("baseScale", baseScale); + out.put("drawScale", drawScale); + out.put("mode", displayMode.get().id()); + out.put("direction", direction.get().id()); + out.put("entryWidth", baseEntryWidth() * baseScale); + out.put("entryHeight", baseEntryHeight() * baseScale); + out.put("gap", BASE_GAP * baseScale); + out.put("blur", blur.get()); + out.put("palette", paletteProps()); + List> itemProps = new ArrayList<>(); + for (Entry entry : visible) { + itemProps.add(entryProps(entry)); + } + out.put("items", itemProps.toArray()); + return out; + } + + private LinkedHashMap paletteProps() { + Themes.Theme t = Theme.theme(); + int alpha = bgAlpha.get(); + int bg1 = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(t.windowBg(), t.surface(), 0.24f), alpha); + int bg2 = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(t.surface(), 0xFF000000, 0.22f), Math.min(255, alpha + 18)); + int stroke = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(t.windowStroke(), t.accentSoft(), 0.22f), 176); + LinkedHashMap out = new LinkedHashMap<>(); + out.put("bg1", hex(bg1)); + out.put("bg2", hex(bg2)); + out.put("stroke", hex(stroke)); + out.put("text", hex(t.textPrimary())); + out.put("muted", hex(t.textMuted())); + out.put("accent", hex(t.accent())); + out.put("glow", hex(HudRenderUtil.setAlpha(t.accent(), 155))); + out.put("blurAlpha", Math.min(1.0f, alpha / 255.0f)); + return out; + } + + private LinkedHashMap entryProps(Entry entry) { + ItemStack stack = entry.stack != null ? entry.stack : ItemStack.EMPTY; + LinkedHashMap out = new LinkedHashMap<>(); + out.put("key", entry.event.id()); + out.put("kind", entry.event.id()); + out.put("label", entry.label); + out.put("stack", stack.copy()); + out.put("item", stack.isEmpty() ? "" : BuiltInRegistries.ITEM.getKey(stack.getItem()).toString()); + out.put("count", stack.isEmpty() ? 1 : stack.getCount()); + out.put("damage", stack.isEmpty() ? 0 : stack.getDamageValue()); + out.put("maxDamage", stack.isEmpty() ? 0 : stack.getMaxDamage()); + out.put("alpha", AnimationUtility.clamp01(entry.anim)); + out.put("life", entry.totalLife <= 0.0f ? 1.0f : AnimationUtility.clamp01(entry.life / entry.totalLife)); + out.put("pulse", AnimationUtility.clamp01(entry.punch)); + out.put("accent", hex(entryAccent(entry.event))); + return out; + } + + private int entryAccent(ItemizerEvent event) { + Themes.Theme t = Theme.theme(); + int target = switch (event) { + case AUTO_EAT -> 0xFF74E083; + case AUTO_TOTEM -> 0xFFFFD15C; + case ELYTRA_SWAP -> 0xFF77D7FF; + }; + return ColorMath.colorWithAlpha(HudRenderUtil.mixColor(t.accent(), target, 0.72f), 255); + } + + private void drawCards(Renderer2D renderer, + List visible, + float x, + float y, + float scale, + float globalAlpha) { + if (renderer == null || visible == null || visible.isEmpty()) return; + float entryW = baseEntryWidth() * scale; + float entryH = baseEntryHeight() * scale; + float gap = BASE_GAP * scale; + for (int i = 0; i < visible.size(); i++) { + Entry entry = visible.get(i); + float cardX = x; + float cardY = y; + if (direction.get() == ItemizerDirection.HORIZONTAL) { + cardX += i * (entryW + gap); + } else { + cardY += i * (entryH + gap); + } + + float alpha = AnimationUtility.clamp01(globalAlpha * entry.anim); + if (alpha <= 0.001f) continue; + float radius = Math.max(3.0f * scale, Math.min(entryW, entryH) * 0.24f); + drawGlassCard(renderer, cardX, cardY, entryW, entryH, radius, scale, alpha); + drawClippedTimeStrip(renderer, entry, cardX, cardY, entryW, entryH, radius, scale, alpha); + queueItem(entry, cardX, cardY, entryW, entryH, scale, alpha); + } + } + + private void drawGlassCard(Renderer2D renderer, + float x, + float y, + float width, + float height, + float radius, + float scale, + float alpha) { + if (width <= 0.0f || height <= 0.0f || alpha <= 0.001f) return; + float blurStrength = blur.get() ? (bgAlpha.get() / 255.0f) * alpha : 0.0f; + HudRenderUtil.drawLiquidGlass(x, y, width, height, radius, Math.max(0.001f, scale), true, blurStrength, alpha); + int veil = HudRenderUtil.glassSmallBackground(alpha); + renderer.roundedRectCorners(x, y, width, height, radius, radius, radius, radius, 1.0f, veil); + } + + private void drawClippedTimeStrip(Renderer2D renderer, + Entry entry, + float x, + float y, + float width, + float height, + float cardRadius, + float scale, + float alpha) { + boolean clipped = ClipFunction.pushRoundedRect(x, y, width, height, cardRadius); + if (!clipped) return; + try { + drawTimeStrip(renderer, entry, x, y, width, height, cardRadius, scale, alpha); + } finally { + ClipFunction.pop(); + } + } + + private void drawTimeStrip(Renderer2D renderer, + Entry entry, + float x, + float y, + float width, + float height, + float cardRadius, + float scale, + float alpha) { + if (renderer == null || entry == null || width <= 0.0f || height <= 0.0f || alpha <= 0.001f) return; + float life = entry.totalLife <= 0.0f ? 1.0f : AnimationUtility.clamp01(entry.life / entry.totalLife); + if (life <= 0.001f) return; + + float stripH = Math.max(1.45f * scale, Math.min(3.0f * scale, height * 0.13f)); + float bleed = Math.max(0.65f, 0.45f * scale); + float stripDrawH = stripH + bleed; + float stripY = y + height - stripH; + float bottomR = Math.min(cardRadius, stripDrawH); + int accent = entryAccent(entry.event); + int trackTop = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(0xFF07090D, accent, 0.08f), 0.30f * alpha); + int trackBottom = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(0xFF020305, accent, 0.18f), 0.48f * alpha); + renderer.roundedRectCornersQuad( + x, + stripY, + width, + stripDrawH, + 0.0f, + 0.0f, + bottomR, + bottomR, + 0.0f, + trackTop, + trackTop, + trackBottom, + trackBottom + ); + + float fillW = Math.max(0.0f, width * life); + if (fillW <= 0.35f) return; + float fillRLeft = Math.min(bottomR, fillW * 0.5f); + float fillRRight = life >= 0.985f ? bottomR : Math.min(stripDrawH * 0.5f, fillW * 0.5f); + int fillTopLeft = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFFFFFFFF, 0.28f), 0.92f * alpha); + int fillTopRight = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFFFFFFFF, 0.10f), 0.84f * alpha); + int fillBottomRight = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFF05070A, 0.22f), 0.78f * alpha); + int fillBottomLeft = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(accent, 0xFFFFFFFF, 0.16f), 0.88f * alpha); + renderer.roundedRectCornersQuad( + x, + stripY, + fillW, + stripDrawH, + 0.0f, + life >= 0.985f ? 0.0f : fillRRight, + fillRRight, + fillRLeft, + 0.0f, + fillTopLeft, + fillTopRight, + fillBottomRight, + fillBottomLeft + ); + } + + private void queueItem(Entry entry, + float cardX, + float cardY, + float cardW, + float cardH, + float scale, + float alpha) { + if (entry == null || entry.stack == null || entry.stack.isEmpty()) return; + boolean compact = displayMode.get() == ItemizerDisplayMode.COMPACT; + float iconSize = compact + ? Math.min(16.0f * scale, cardH - 7.0f * scale) + : Math.min(Math.min(16.0f * scale, cardW - 8.0f * scale), cardH - 8.0f * scale); + if (iconSize <= 0.0f) return; + float iconX = compact ? cardX + 4.5f * scale : cardX + (cardW - iconSize) * 0.5f; + float iconY = cardY + (cardH - iconSize) * 0.5f - 0.5f * scale; + int overlayFlags = compact ? Renderer2D.ITEM_OVERLAY_ALL : Renderer2D.ITEM_OVERLAY_NONE; + itemTasks.add(new ItemRenderTask(entry.stack.copy(), iconX, iconY, Math.max(0.05f, iconSize / 16.0f), overlayFlags, alpha)); + } + + private void drawItem(Renderer2D renderer, + ItemStack stack, + float x, + float y, + float itemScale, + int seed, + int overlayFlags, + float alpha) { + if (renderer == null || stack == null || stack.isEmpty() || alpha <= 0.001f) return; + double previousAlpha = renderer.getAlpha(); + renderer.setAlpha(previousAlpha * AnimationUtility.clamp01(alpha)); + try { + renderer.item(stack, x, y, itemScale, seed, overlayFlags, null); + } finally { + renderer.setAlpha(previousAlpha); + } + } + + private long signature(Map props) { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, displayMode.get().id()); + h = CachedUiScriptRuntime.mix(h, direction.get().id()); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + h = CachedUiScriptRuntime.mix(h, bgAlpha.get()); + h = CachedUiScriptRuntime.mix(h, blur.get()); + Object itemsValue = props.get("items"); + Object[] items = itemsValue instanceof Object[] arr ? arr : new Object[0]; + h = CachedUiScriptRuntime.mix(h, items.length); + for (Object itemValue : items) { + if (!(itemValue instanceof Map item)) continue; + h = CachedUiScriptRuntime.mix(h, string(item.get("key"))); + h = CachedUiScriptRuntime.mix(h, string(item.get("label"))); + h = CachedUiScriptRuntime.mix(h, string(item.get("item"))); + h = CachedUiScriptRuntime.mix(h, intValue(item.get("count"))); + h = CachedUiScriptRuntime.mix(h, Math.round(floatValue(item.get("alpha")) * 1000.0f)); + h = CachedUiScriptRuntime.mix(h, Math.round(floatValue(item.get("life")) * 1000.0f)); + h = CachedUiScriptRuntime.mix(h, Math.round(floatValue(item.get("pulse")) * 1000.0f)); + } + return h; + } + + private float baseEntryWidth() { + return displayMode.get() == ItemizerDisplayMode.COMPACT ? BASE_COMPACT_W : BASE_ICON_CARD; + } + + private float baseEntryHeight() { + return displayMode.get() == ItemizerDisplayMode.COMPACT ? BASE_COMPACT_H : BASE_ICON_CARD; + } + + private static String hex(int argb) { + return String.format("#%08X", argb); + } + + private static String string(Object value) { + return value instanceof String s ? s : ""; + } + + private static int intValue(Object value) { + return value instanceof Number n ? n.intValue() : 0; + } + + private static float floatValue(Object value) { + return value instanceof Number n ? n.floatValue() : 0.0f; + } + + public enum ItemizerEvent implements EnumValue.IdProvider { + AUTO_EAT("auto_eat", "Eat"), + AUTO_TOTEM("auto_totem", "Totem"), + ELYTRA_SWAP("elytra_swap", "Swap"); + + private final String id; + private final String fallbackLabel; + + ItemizerEvent(String id, String fallbackLabel) { + this.id = id; + this.fallbackLabel = fallbackLabel; + } + + @Override + public String id() { + return id; + } + } + + private enum ItemizerDisplayMode implements EnumValue.IdProvider { + ICONS("icons"), + COMPACT("compact"); + + private final String id; + + ItemizerDisplayMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum ItemizerDirection implements EnumValue.IdProvider { + HORIZONTAL("horizontal"), + VERTICAL("vertical"); + + private final String id; + + ItemizerDirection(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private static final class Entry { + private final ItemizerEvent event; + private ItemStack stack = ItemStack.EMPTY; + private String label; + private float life; + private float totalLife; + private float anim; + private float punch; + + private Entry(ItemizerEvent event) { + this.event = event; + this.label = event.fallbackLabel; + } + } + + private record ItemRenderTask(ItemStack stack, float x, float y, float scale, int overlayFlags, float alpha) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Keybinds.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Keybinds.java new file mode 100644 index 0000000..071d736 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Keybinds.java @@ -0,0 +1,603 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.util.Util; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.clickgui.settings.FunctionBindSetting; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleCategory; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.util.LinkedHashMap; +import java.util.*; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 45) +public final class Keybinds extends DraggableHudElement { + + { + defaultLinkedLayout(456.6228f, 248.0f, "potions", "RIGHT", 0.0f, 0.0f); + } + + private static final float HEADER_HEIGHT = 15.5f; + private static final float CONTENT_START_Y = 25.0f; + private static final float ROW_STEP = 11.0f; + private static final float MIN_WIDTH = 102.0f; + private static final float HEADER_ICON_SCALE = 1.18f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float COUNT_LABEL_OFFSET = 22.0f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String MODE_PRESS = "Press"; + private static final String MODE_HOLD = "Hold"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedListHudPanel scriptedPanel = new ScriptedListHudPanel(); + private final NumberValue scaleValue = + new NumberValue<>("keybinds_scale", 1.0, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + private final ModeValue layoutMode = + new ModeValue("keybinds_layout", "Unified Divider", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + private final ModeValue colorMode = + new ModeValue("keybinds_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("keybinds_bg_effect", "None", EFFECT_NONE, EFFECT_BLUR); + private final RGBAColorValue bg = + new RGBAColorValue("keybinds_bg", "#F7343434"); + private final RGBAColorValue bg2 = + new RGBAColorValue("keybinds_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("keybinds_bg_alpha", 225, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("keybinds_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("keybinds_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("keybinds_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("keybinds_blur_alpha", 140, 0, 255); + private final BooleanValue localActions = + new BooleanValue("keybinds_local_actions", true); + private final BooleanMapValue localActionModes = + new BooleanMapValue("keybinds_local_action_modes", new LinkedHashMap<>() {{ + put(MODE_PRESS, true); + put(MODE_HOLD, true); + }}); + private final BooleanValue headerIconPulse = + new BooleanValue("keybinds_header_icon_pulse", true); + private final NumberValue headerIconPulseSpeed = + new NumberValue<>("keybinds_header_icon_pulse_speed", 18, 1, 60); + private final NumberValue headerIconPulseIntensity = + new NumberValue<>("keybinds_header_icon_pulse_intensity", 100, 0, 100); + + private final Map entryAnim = new LinkedHashMap<>(); + private final Map lastEntries = new LinkedHashMap<>(); + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiDivider; + private int uiBlurTint; + + public Keybinds() { + super("keybinds", "Keybinds", true); + } + + private static String iconFor(ModuleCategory category) { + if (category == null) return "ellipsis"; + return switch (category) { + case COMBAT -> "swords"; + case MOVEMENT -> "accessibility"; + case PLAYER -> "user"; + case VISUALS -> "tree-pine"; + default -> "ellipsis"; + }; + } + + private static String cleanActionLabel(FunctionBindSetting setting) { + if (setting == null) return ""; + String explicit = setting.getHudLabel(); + if (explicit != null && !explicit.isBlank()) return explicit; + + String actionId = setting.getActionId(); + String display = setting.getDisplayName(); + if (display == null || display.isBlank() || display.equals("Function: " + actionId)) { + display = actionId; + } + display = display.replace("Function: ", "").replace('_', ' ').replace('-', ' ').trim(); + if (display.isEmpty()) return ""; + + StringBuilder out = new StringBuilder(display.length()); + boolean cap = true; + for (int i = 0; i < display.length(); i++) { + char c = display.charAt(i); + if (Character.isWhitespace(c)) { + if (!out.isEmpty() && out.charAt(out.length() - 1) != ' ') out.append(' '); + cap = true; + continue; + } + out.append(cap ? Character.toUpperCase(c) : c); + cap = false; + } + return out.toString(); + } + + private static String formatBind(String raw) { + if (raw == null || raw.isBlank()) return ""; + String[] parts = raw.trim().toUpperCase(Locale.ROOT).split("\\+"); + List out = new ArrayList<>(parts.length); + for (String part : parts) { + String mapped = formatBindPart(part.trim()); + if (!mapped.isEmpty()) out.add(mapped); + } + return String.join("+", out); + } + + private static String formatBindPart(String value) { + return switch (value) { + case "LEFT_CONTROL", "RIGHT_CONTROL", "LEFT_CTRL", "RIGHT_CTRL", "CONTROL", "CTRL" -> "CTRL"; + case "LEFT_SHIFT", "RIGHT_SHIFT", "SHIFT" -> "SHIFT"; + case "LEFT_ALT", "RIGHT_ALT", "ALT" -> "ALT"; + case "LEFT_SUPER", "RIGHT_SUPER", "SUPER" -> "WIN"; + case "GRAVE_ACCENT" -> "`"; + case "COMMA" -> ","; + case "PERIOD" -> "."; + case "SEMICOLON" -> ";"; + case "APOSTROPHE" -> "'"; + case "SLASH" -> "/"; + case "BACKSLASH" -> "\\"; + case "MINUS" -> "-"; + case "EQUAL" -> "="; + case "SPACE" -> "SPC"; + case "ENTER" -> "ENT"; + case "ESCAPE" -> "ESC"; + case "DELETE" -> "DEL"; + case "INSERT" -> "INS"; + case "CAPS_LOCK" -> "CAPS"; + case "LEFT_BRACKET" -> "["; + case "RIGHT_BRACKET" -> "]"; + default -> value.startsWith("MOUSE_BUTTON_") ? "M" + value.substring("MOUSE_BUTTON_".length()) : value; + }; + } + + private static int clamp255(int value) { + return Math.max(0, Math.min(255, value)); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(localActions)); + defs.add(SettingDef.group("keybinds_local_action_modes", localActionModes).visibleWhen(localActions::get)); + defs.add(SettingDef.bool(headerIconPulse)); + defs.add(SettingDef.number(headerIconPulseSpeed).visibleWhen(headerIconPulse::get)); + defs.add(SettingDef.number(headerIconPulseIntensity).visibleWhen(headerIconPulse::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 128.0f; + this.y = 80.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !forceVisible)) { + width = 0.0f; + height = 0.0f; + return; + } + + boolean chatPreview = ClientScreen.current() instanceof ChatScreen; + List rows = collectRows(); + List animatedRows = animateRows(rows); + boolean showExampleRow = rows.isEmpty() && (forceVisible || chatPreview); + boolean showWidget = !rows.isEmpty() || showExampleRow; + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + if (animatedRows.isEmpty() && !showExampleRow && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + TextRenderer headerIconRenderer = Fonts.renderer("Icons", FontInfo.Type.Regular, TextRenderer.get()); + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer rowTextRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerIconRenderer == null) headerIconRenderer = textRenderer; + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (rowTextRenderer == null) rowTextRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(scaleValue); + float rowStep = ROW_STEP * baseScale; + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + + headerIconRenderer.begin(fontScale * HEADER_ICON_SCALE, true, false); + float headerIconH = (float) headerIconRenderer.getHeight(false); + headerIconRenderer.end(); + + headerTextRenderer.begin(fontScale, true, false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Keybinds", false); + headerTextRenderer.end(); + + rowTextRenderer.begin(fontScale, true, false); + float rowTextH = (float) rowTextRenderer.getHeight(false); + float maxWidth = MIN_WIDTH * baseScale; + if (showExampleRow) { + float previewWidth = (float) rowTextRenderer.getWidth("KillAuraG", false) + (34.0f * baseScale); + maxWidth = Math.max(maxWidth, previewWidth); + } else { + for (AnimatedRow animatedRow : animatedRows) { + Row row = animatedRow.row(); + float widthCandidate = (float) rowTextRenderer.getWidth(row.name() + row.bindText(), false) + (34.0f * baseScale); + maxWidth = Math.max(maxWidth, widthCandidate); + } + } + rowTextRenderer.end(); + + rowTextRenderer.begin(fontScale * 0.92f, false, false); + String activeCountText = Integer.toString(rows.size()); + float activeLabelWidth = (float) rowTextRenderer.getWidth("Active:", false); + float activeCountWidth = (float) rowTextRenderer.getWidth(activeCountText, false); + rowTextRenderer.end(); + + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + activeLabelWidth + activeCountWidth + + ((COUNT_LABEL_OFFSET + 12.0f) * baseScale); + maxWidth = Math.max(maxWidth, headerWidth); + + float contentHeight = showExampleRow ? rowStep : totalAnimatedHeight(animatedRows, rowStep); + float targetHeight = (showExampleRow || !animatedRows.isEmpty()) + ? (CONTENT_START_Y * baseScale + contentHeight) + : (HEADER_HEIGHT * baseScale); + + displayWidth = HudRenderUtil.animateDimension(displayWidth, maxWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + if (!showWidget && widgetScale <= 0.001f && animatedRows.isEmpty()) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderIconH = headerIconH * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawRowTextH = rowTextH * drawScale; + int resolvedHeaderIconColor = uiCounter; + if (headerIconPulse.get()) { + int pulsedHeaderIconColor = HudTextEffects.animatedColor( + uiCounter, + HudTextEffects.Effect.PULSE, + headerIconPulseSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + resolvedHeaderIconColor = HudRenderUtil.mixColor( + uiCounter, + pulsedHeaderIconColor, + headerIconPulseIntensity.get() / 100.0f + ); + } + + List> panelRows = ScriptedListHudPanel.rows(); + if (showExampleRow) { + LinkedHashMap row = ScriptedListHudPanel.row( + "preview", + "svg", + "swords", + List.of(ScriptedListHudPanel.textPart("KillAura", withAlpha(uiText & 0x00FFFFFF, 255), 0.0f)), + "G", + withAlpha(uiCounter & 0x00FFFFFF, 255), + withAlpha(uiMuted & 0x00FFFFFF, 130), + 1.0f + ); + row.put("iconTint", ScriptedListHudPanel.hex(withAlpha(uiCounter & 0x00FFFFFF, 255))); + panelRows.add(row); + } else { + for (AnimatedRow animatedRow : animatedRows) { + float anim = animatedRow.anim(); + if (anim <= 0.01f) continue; + Row rowData = animatedRow.row(); + int rowAlpha = clamp255(Math.round(255.0f * anim)); + int rowNameColor = rowData.local() + ? withAlpha(uiCounter & 0x00FFFFFF, rowAlpha) + : withAlpha(uiText & 0x00FFFFFF, rowAlpha); + int rowRightColor = withAlpha(uiCounter & 0x00FFFFFF, rowAlpha); + int rowDividerColor = rowData.local() + ? withAlpha(uiCounter & 0x00FFFFFF, Math.max(42, rowAlpha - 94)) + : withAlpha(uiMuted & 0x00FFFFFF, Math.max(26, rowAlpha - 120)); + LinkedHashMap row = ScriptedListHudPanel.row( + rowData.key(), + "svg", + rowData.icon(), + List.of(ScriptedListHudPanel.textPart(rowData.name(), rowNameColor, 0.0f)), + rowData.bindText(), + rowRightColor, + rowDividerColor, + anim + ); + row.put("iconTint", ScriptedListHudPanel.hex(withAlpha(uiCounter & 0x00FFFFFF, rowAlpha))); + panelRows.add(row); + } + } + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedListHudPanel.Panel( + ScriptedListHudPanel.KEYBINDS, + new ScriptedListHudPanel.Palette(uiHeaderLeft, uiHeaderRight, uiBodyLeft, uiBodyRight, + uiOutline, uiText, uiMuted, uiCounter, uiTitleText, uiDivider, uiBlurTint), + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderIconH, + drawHeaderTextH, + drawRowTextH, + activeLabelWidth * drawScale, + activeCountWidth * drawScale, + rows.size(), + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + resolvedHeaderIconColor, + HudPanelLayoutModes.current(layoutMode), + panelRows + ) + ); + } + + private List collectRows() { + List rows = new ArrayList<>(); + for (Module module : ModuleManager.getModules()) { + if (module == null || !module.isEnabled()) continue; + String name = module.getDisplayName(); + if (name == null || name.isBlank()) name = module.name(); + String moduleIcon = iconFor(module.getCategory()); + + if (module.getKeyBindSetting() != null && module.getKeyBindSetting().getValue() != null) { + String rawBind = module.getKeyBindSetting().getValue().get(); + if (rawBind != null && !rawBind.isBlank() && !"NONE".equalsIgnoreCase(rawBind)) { + rows.add(new Row( + module.name(), + name, + formatBind(rawBind), + moduleIcon, + false + )); + } + } + + for (FunctionBindSetting action : module.getActionSettings()) { + if (action == null || !isLocalActionVisible(action)) continue; + String rawBind = action.get(); + if (rawBind == null || rawBind.isBlank() || "NONE".equalsIgnoreCase(rawBind)) continue; + + String actionLabel = cleanActionLabel(action); + if (actionLabel.isBlank()) actionLabel = action.getActionId(); + String actionIcon = action.getHudIcon(); + if (actionIcon == null || actionIcon.isBlank()) actionIcon = moduleIcon; + + rows.add(new Row( + module.name() + ":action:" + action.getActionId(), + name + ": " + actionLabel, + formatBind(rawBind), + actionIcon, + true + )); + } + } + rows.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.name(), b.name())); + return rows; + } + + private boolean isLocalActionVisible(FunctionBindSetting action) { + if (!localActions.get()) return false; + BindMode mode = action.getMode(); + if (mode == BindMode.HOLD) { + return localActionModes.get(MODE_HOLD) && action.isHeldForHud(); + } + return localActionModes.get(MODE_PRESS) && action.isHudToggle() && action.isHudToggleActive(); + } + + private List animateRows(List rows) { + Map current = new LinkedHashMap<>(); + for (Row row : rows) { + current.put(row.key(), row); + } + + Map merged = new LinkedHashMap<>(); + merged.putAll(current); + for (Map.Entry entry : lastEntries.entrySet()) { + merged.putIfAbsent(entry.getKey(), entry.getValue()); + } + lastEntries.clear(); + lastEntries.putAll(merged); + + List toRemove = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + String key = entry.getKey(); + float target = current.containsKey(key) ? 1.0f : 0.0f; + float anim = entryAnim.getOrDefault(key, 0.0f); + anim = AnimationUtility.approach(anim, target, AnimationUtility.deltaTime(), 12.0f); + anim = AnimationUtility.snap(anim, target, 0.02f); + if (anim <= 0.0f && target <= 0.0f) { + toRemove.add(key); + } else { + entryAnim.put(key, anim); + } + } + for (String key : toRemove) { + entryAnim.remove(key); + lastEntries.remove(key); + } + + List out = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + float anim = entryAnim.getOrDefault(entry.getKey(), 0.0f); + if (anim > 0.01f) { + out.add(new AnimatedRow(entry.getValue(), anim)); + } + } + return out; + } + + private float totalAnimatedHeight(List rows, float rowStep) { + float out = 0.0f; + for (AnimatedRow row : rows) { + out += rowStep * row.anim(); + } + return out; + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + return; + } + + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + private int withAlpha(int rgb, int alpha) { + return (clamp255(alpha) << 24) | (rgb & 0x00FFFFFF); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private record Row(String key, String name, String bindText, String icon, boolean local) { + } + + private record AnimatedRow(Row row, float anim) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/MediaPlayer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/MediaPlayer.java new file mode 100644 index 0000000..486eef9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/MediaPlayer.java @@ -0,0 +1,740 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.resources.language.I18n; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.events.impl.RenderPrewarmCollectEvent; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.media.MediaInfo; +import dev.loki.lovisual.util.media.MediaSessionService; +import dev.loki.lovisual.util.media.RepeatMode; + +import java.util.List; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 70) +public final class MediaPlayer extends DraggableHudElement { + + { + defaultLayout(12.0f, 82.30222f); + } + + + private static final float BASE_PADDING = 6f; + private static final float BASE_GAP = 6f; + private static final float BASE_ART_SIZE = 30f; + private static final float BASE_ART_BOOST = 3f; + private static final float BASE_ART_OVERHANG = 4f; + private static final float BASE_RADIUS = 6f; + private static final float BASE_BAR_H = 2.4f; + private static final float BASE_BUTTON_SIZE = 16f; + private static final float BASE_BUTTON_GAP = 8f; + private static final float BASE_TEXT_GAP = 2f; + private static final float BASE_BAR_GAP = 4f; + private static final float BASE_BOTTOM_GAP = 4f; + private static final float BASE_TITLE_SCROLL_SPEED = 22f; + private static final float BASE_TITLE_SCROLL_GAP = 14f; + private static final float BASE_TITLE_FADE = 12f; + private static final float TITLE_SCROLL_PAUSE_SEC = 0.8f; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private static final float BASE_CONTENT_W = 170f; + + private static final int ICON_PAUSE = 0xEA02; + private static final int ICON_PLAY = 0xEA03; + private static final int ICON_PREV = 0xEA04; + private static final int ICON_NEXT = 0xEA05; + private static final int ICON_SHUFFLE = 0xEA06; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final MediaSessionService mediaService = MediaSessionService.get(); + private final NumberValue scale = + new NumberValue<>("media_player_scale", 1.51, 0.5, 5.0); + private final BooleanValue syncTheme = + new BooleanValue("media_player_sync_theme", true); + private final RGBAColorValue bg = + new RGBAColorValue("media_player_bg", "#60000000"); + private final NumberValue bgAlpha = + new NumberValue<>("media_player_bg_alpha", 124, 0, 255); + private final RGBColorValue accent = + new RGBColorValue("media_player_accent", "#FF2F2F"); + private final RGBColorValue text = + new RGBColorValue("media_player_text", "#FFFFFF"); + private final ModeValue bgEffect = + new ModeValue("media_player_bg_effect", "None", EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS); + private final NumberValue blurAlpha = + new NumberValue<>("media_player_blur_alpha", 255, 0, 255); + private final BooleanValue gradient = + new BooleanValue("media_player_gradient", true); + private float anim = 0f; + private MediaSessionService.Snapshot currentSnapshot = MediaSessionService.Snapshot.empty(MediaSessionService.isMediaAvailable()); + + private float playHover; + private float shuffleHover; + private float prevHover; + private float nextHover; + private float repeatHover; + + private float shuffleX; + private float shuffleY; + private float playX; + private float playY; + private float prevX; + private float prevY; + private float nextX; + private float nextY; + private float repeatX; + private float repeatY; + private float buttonSize; + private int uiAccent; + private int uiAccentSoft; + private int uiText; + private int uiTextMuted; + + public MediaPlayer() { + super("media_player", "Media Player", false); + } + + private static String iconString(int codepoint) { + return new String(Character.toChars(codepoint)); + } + + private static TextRenderer getMediaIcons(TextRenderer fallback) { + return Fonts.renderer("MediaPlayer", FontInfo.Type.Regular, fallback); + } + + public void onResourceReload() { + mediaService.onResourceReload(); + currentSnapshot = MediaSessionService.Snapshot.empty(MediaSessionService.isMediaAvailable()); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scale)); + defs.add(SettingDef.bool(syncTheme)); + defs.add(SettingDef.color(bg).visibleWhen(() -> !syncTheme.get() && !isGlassEffect())); + defs.add(SettingDef.number(bgAlpha).visibleWhen(() -> syncTheme.get() && !isGlassEffect())); + defs.add(SettingDef.colorNoAlpha(accent).visibleWhen(() -> !syncTheme.get())); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(() -> !syncTheme.get())); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(gradient).visibleWhen(() -> syncTheme.get() && !isGlassEffect())); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + float scale = HudScale.scale(screenW, screenH) * this.scale.get().floatValue(); + this.x = 20f * scale; + this.y = 20f * scale; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public boolean isMouseOverInteractive(float mx, float my) { + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + if (buttonSize <= 0f) return false; + boolean showShuffle = currentSnapshot != null && currentSnapshot.supportsShuffle(); + boolean showRepeat = currentSnapshot != null && currentSnapshot.supportsRepeat(); + return hit(mx, my, playX, playY, buttonSize, buttonSize) + || (showShuffle && hit(mx, my, shuffleX, shuffleY, buttonSize, buttonSize)) + || hit(mx, my, prevX, prevY, buttonSize, buttonSize) + || hit(mx, my, nextX, nextY, buttonSize, buttonSize) + || (showRepeat && hit(mx, my, repeatX, repeatY, buttonSize, buttonSize)); + } + + @Override + public boolean onMouseClicked(float mx, float my, int button) { + if (!(ClientScreen.current() instanceof ChatScreen)) return false; + if (currentSnapshot == null || !currentSnapshot.hasSession()) return false; + if (button != 0) return false; + + boolean consumed = false; + try { + if (currentSnapshot.supportsShuffle() && hit(mx, my, shuffleX, shuffleY, buttonSize, buttonSize)) { + mediaService.toggleShuffle(); + consumed = true; + } else if (hit(mx, my, playX, playY, buttonSize, buttonSize)) { + mediaService.playPause(); + consumed = true; + } else if (hit(mx, my, prevX, prevY, buttonSize, buttonSize)) { + mediaService.previous(); + consumed = true; + } else if (hit(mx, my, nextX, nextY, buttonSize, buttonSize)) { + mediaService.next(); + consumed = true; + } else if (currentSnapshot.supportsRepeat() && hit(mx, my, repeatX, repeatY, buttonSize, buttonSize)) { + mediaService.cycleRepeatMode(); + consumed = true; + } + } catch (Throwable ignored) { + } + return consumed; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (mc == null) return; + boolean chatOpen = ClientScreen.current() instanceof ChatScreen; + boolean preview = DraggableHudElementRegistry.isForceVisible(); + + MediaSessionService.Snapshot snapshot = mediaService.snapshot(); + currentSnapshot = snapshot; + MediaInfo media = snapshot.media(); + boolean hasSession = snapshot.hasSession(); + boolean mediaAvailable = snapshot.mediaAvailable(); + boolean shouldShow = hasSession || chatOpen || preview; + + float target = shouldShow ? 1.0f : 0.0f; + anim = lerp(anim, target, 0.25f); + if (!shouldShow && anim <= 0.02f) { + width = 0f; + height = 0f; + updateButtons(); + return; + } + + TextRenderer titleRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + TextRenderer artistRenderer = Fonts.renderer("InterMedium", FontInfo.Type.Regular, textRenderer); + TextRenderer metaRenderer = Fonts.renderer("Inter", FontInfo.Type.Regular, textRenderer); + TextRenderer iconRenderer = getMediaIcons(textRenderer); + + float mediaScale = scale.get().floatValue(); + float baseScale = HudScale.scale(screenW, screenH) * mediaScale; + float fontScale = (hud.getFontSize() / 18f) * mediaScale; + float padding = BASE_PADDING * baseScale; + float gap = BASE_GAP * baseScale; + float artBase = BASE_ART_SIZE * baseScale; + float artBoost = BASE_ART_BOOST * baseScale; + float artOverhang = BASE_ART_OVERHANG * baseScale; + float radius = BASE_RADIUS * baseScale; + float barH = BASE_BAR_H * baseScale; + float textGap = BASE_TEXT_GAP * baseScale; + float barGap = BASE_BAR_GAP * baseScale; + float bottomGap = BASE_BOTTOM_GAP * baseScale; + float buttonGap = BASE_BUTTON_GAP * baseScale; + float contentW = BASE_CONTENT_W * baseScale; + + float titleScale = 0.95f * fontScale; + float artistScale = 0.78f * fontScale; + float metaScale = 0.70f * fontScale; + float iconScale = 0.9f * fontScale; + + String title = hasSession ? safe(media.title()) : ""; + String artist = hasSession ? safe(media.artist()) : ""; + String owner = hasSession && snapshot.session() != null ? safe(snapshot.session().getOwner()) : ""; + long predictedPos = hasSession ? snapshot.predictedPositionSeconds() : 0L; + long predictedDur = hasSession ? snapshot.durationSeconds() : 0L; + String timeText = hasSession ? formatTime(predictedPos) : ""; + + metaRenderer.begin(metaScale, false, false); + float timeW = hasSession ? (float) metaRenderer.getWidth(timeText, false) : 0f; + metaRenderer.end(); + + final boolean showShuffle = hasSession && snapshot.supportsShuffle(); + final boolean showRepeat = hasSession && snapshot.supportsRepeat(); + final int activeControls = 3 + (showShuffle ? 1 : 0) + (showRepeat ? 1 : 0); + final float btnSizeBase = BASE_BUTTON_SIZE * baseScale; + + float maxOwnerW = Math.max(0f, contentW - timeW - buttonGap); + String ownerText = hasSession ? trimText(metaRenderer, owner, maxOwnerW, metaScale) : ""; + + + String titleText = hasSession ? title : ""; + String artistText = hasSession ? trimText(artistRenderer, artist, contentW, artistScale) : ""; + + titleRenderer.begin(titleScale, false, false); + float titleW = hasSession ? (float) titleRenderer.getWidth(titleText, false) : 0f; + float titleH = (float) titleRenderer.getHeight(false); + titleRenderer.end(); + + artistRenderer.begin(artistScale, false, false); + if (hasSession) { + artistRenderer.getWidth(artistText, false); + } + float artistH = (float) artistRenderer.getHeight(false); + artistRenderer.end(); + + metaRenderer.begin(metaScale, false, false); + float metaH = (float) metaRenderer.getHeight(false); + float ownerW = hasSession ? (float) metaRenderer.getWidth(ownerText, false) : 0f; + metaRenderer.end(); + + float contentH = titleH + textGap + artistH + barGap + barH + bottomGap + metaH; + float artSize = Math.max(artBase, contentH + artBoost); + float leftPad = padding + artOverhang; + + float bodyH = Math.max(contentH, artSize); + + float boxW = leftPad + padding + artSize + gap + contentW; + float boxH = padding * 2f + bodyH; + + width = boxW; + height = boxH; + + float scale = Math.max(0f, Math.min(1f, anim)); + float drawX = x + (boxW - boxW * scale) * 0.5f; + float drawY = y + (boxH - boxH * scale) * 0.5f; + + boolean useSyncTheme = syncTheme.get(); + boolean blurEnabled = isBlurEffect(); + boolean glassEnabled = isGlassEffect(); + int baseRgb = glassEnabled ? HudRenderUtil.glassBackgroundRgb() + : (useSyncTheme ? (theme().windowBg() & 0x00FFFFFF) : (bg.getArgb() & 0x00FFFFFF)); + int bgAlpha = getBgAlpha(); + int bg = (bgAlpha << 24) | baseRgb; + int accent = useSyncTheme ? theme().accent() : (this.accent.getArgb() | 0xFF000000); + int accentSoft = useSyncTheme + ? theme().accentSoft() + : HudRenderUtil.mixColor(accent, bg, 0.55f); + int stroke = HudRenderUtil.scaleAlpha(accent, 0.75f); + int strokeSoft = HudRenderUtil.scaleAlpha(accentSoft, 0.45f); + int borderColor = HudRenderUtil.mixColor(stroke, strokeSoft, 0.5f); + uiAccent = accent; + uiAccentSoft = accentSoft; + uiText = useSyncTheme ? theme().textPrimary() : (text.getArgb() | 0xFF000000); + uiTextMuted = useSyncTheme ? theme().textMuted() : HudRenderUtil.mixColor(uiText, bg, 0.6f); + + if (glassEnabled) { + drawGlass(drawX, drawY, boxW * scale, boxH * scale, radius * scale, baseScale * scale, uiAccentSoft); + } else if (blurEnabled) { + drawBlur(drawX, drawY, boxW * scale, boxH * scale, radius * scale, bg); + } + + int bgScaled = glassEnabled + ? HudRenderUtil.glassPanelBackground(scale) + : HudRenderUtil.scaleAlpha(bg, scale); + boolean useGradient = !glassEnabled && useSyncTheme && gradient.get(); + HudRenderUtil.drawHudBackground(renderer, drawX, drawY, boxW * scale, boxH * scale, radius * scale, 1.0f, bgScaled, useGradient); + if (!glassEnabled) { + renderer.roundedRectStroke(drawX, drawY, boxW * scale, boxH * scale, radius * scale, 1.0f, 0.9f * baseScale * scale, + HudRenderUtil.scaleAlpha(borderColor, scale)); + } + + float contentY = padding + (bodyH - contentH) * 0.5f; + + if (!hasSession) { + String statusKey = mediaAvailable + ? "hud.media_player.available" + : "hud.media_player.unavailable"; + String status = I18n.get(statusKey); + + metaRenderer.begin(metaScale * scale, false, false); + float statusW = (float) metaRenderer.getWidth(status, false); + float statusH = (float) metaRenderer.getHeight(false); + metaRenderer.end(); + + float sx = drawX + (boxW * scale - statusW) * 0.5f; + float sy = drawY + (boxH * scale - statusH) * 0.5f; + + metaRenderer.begin(metaScale * scale, false, false); + metaRenderer.render(status, sx, sy, new RenderColor(HudRenderUtil.scaleAlpha(uiTextMuted, scale)), false); + metaRenderer.end(); + updateButtons(); + return; + } + + float artX = leftPad - (2f * baseScale); + float artY = padding + (bodyH - artSize) * 0.5f; + float textX = leftPad + artSize + gap; + float artistY = contentY + titleH + textGap; + float barY = artistY + artistH + barGap; + float bottomY = barY + barH + bottomGap; + + float drawArtX = drawX + artX * scale; + float drawArtY = drawY + artY * scale; + float drawArtSize = artSize * scale; + float artRadius = Math.min(radius * 1.2f, artSize * 0.22f) * scale; + + Identifier artworkTexture = snapshot.artworkTexture(); + if (artworkTexture != null) { + Renderer2D.TEXTURE.roundedTexRect(drawArtX, drawArtY, drawArtSize, drawArtSize, artRadius, + 1.0f, HudRenderUtil.scaleAlpha(0xFFFFFFFF, scale), artworkTexture); + } else { + renderer.roundedRect(drawArtX, drawArtY, drawArtSize, drawArtSize, artRadius, + 1.0f, HudRenderUtil.scaleAlpha(0x44111111, scale)); + renderer.roundedRectStroke(drawArtX, drawArtY, drawArtSize, drawArtSize, artRadius, + 1.0f, 0.9f * baseScale * scale, HudRenderUtil.scaleAlpha(uiAccentSoft, scale * 0.9f)); + } + + float titleRenderX = drawX + textX * scale; + float titleRenderY = drawY + contentY * scale; + float titleRenderW = contentW * scale; + float titleRenderH = titleH * scale; + boolean titleScroll = titleW > contentW * 1.01f; + + if (titleScroll) { + drawScrollingTitle(titleRenderer, titleText, titleRenderX, titleRenderY, titleW * scale, titleRenderW, + titleScale * scale, baseScale, scale); + } else { + titleRenderer.begin(titleScale * scale, false, false); + titleRenderer.render(titleText, titleRenderX, titleRenderY, new RenderColor(HudRenderUtil.scaleAlpha(uiText, scale)), false); + titleRenderer.end(); + } + + artistRenderer.begin(artistScale * scale, false, false); + artistRenderer.render(artistText, drawX + textX * scale, drawY + artistY * scale, + new RenderColor(HudRenderUtil.scaleAlpha(uiTextMuted, scale)), false); + artistRenderer.end(); + + float barX = drawX + textX * scale; + float barW = contentW * scale; + float barRadius = Math.max(1f, barH * 0.5f) * scale; + int barBg = HudRenderUtil.scaleAlpha(0x55000000, scale); + renderer.roundedRect(barX, drawY + barY * scale, barW, barH * scale, barRadius, 1.0f, barBg); + + float progress = predictedDur > 0 ? Math.min(1.0f, Math.max(0.0f, + (float) predictedPos / (float) predictedDur)) : 0f; + int barFill = HudRenderUtil.scaleAlpha(uiAccent, scale); + renderer.roundedRect(barX, drawY + barY * scale, barW * progress, barH * scale, barRadius, 1.0f, barFill); + + metaRenderer.begin(metaScale * scale, false, false); + metaRenderer.render(timeText, drawX + textX * scale, drawY + bottomY * scale, + new RenderColor(HudRenderUtil.scaleAlpha(uiTextMuted, scale)), false); + + float ownerX = drawX + (textX + contentW - ownerW) * scale; + metaRenderer.render(ownerText, ownerX, drawY + bottomY * scale, + new RenderColor(HudRenderUtil.scaleAlpha(uiTextMuted, scale)), false); + metaRenderer.end(); + + buttonSize = btnSizeBase * scale; + + float controlsReservedW = btnSizeBase * activeControls + buttonGap * Math.max(0, activeControls - 1); + float controlsX = textX + (contentW - controlsReservedW) * 0.5f; + float controlsY = bottomY + (metaH - btnSizeBase) * 0.5f; + float cursorX = controlsX; + + if (showShuffle) { + shuffleX = drawX + cursorX * scale; + shuffleY = drawY + controlsY * scale; + cursorX += btnSizeBase + buttonGap; + } else { + shuffleX = shuffleY = 0f; + } + + prevX = drawX + cursorX * scale; + prevY = drawY + controlsY * scale; + cursorX += btnSizeBase + buttonGap; + + playX = drawX + cursorX * scale; + playY = prevY; + cursorX += btnSizeBase + buttonGap; + + nextX = drawX + cursorX * scale; + nextY = prevY; + cursorX += btnSizeBase + buttonGap; + + if (showRepeat) { + repeatX = drawX + cursorX * scale; + repeatY = prevY; + } else { + repeatX = repeatY = 0f; + } + + float controlIconScale = iconScale * 1.05f; + + updateHover(chatOpen, showShuffle, showRepeat); + if (showShuffle) { + drawControl(renderer, iconRenderer, iconString(ICON_SHUFFLE), + shuffleX, shuffleY, buttonSize, shuffleHover, scale, controlIconScale, shuffleColor(snapshot, scale)); + } + + drawControl(renderer, iconRenderer, iconString(ICON_PREV), + prevX, prevY, buttonSize, prevHover, scale, controlIconScale, HudRenderUtil.scaleAlpha(uiText, scale)); + + drawControl(renderer, iconRenderer, + iconString(snapshot.isPlaying() ? ICON_PAUSE : ICON_PLAY), + playX, playY, buttonSize, playHover, scale, controlIconScale, HudRenderUtil.scaleAlpha(uiText, scale)); + + drawControl(renderer, iconRenderer, iconString(ICON_NEXT), + nextX, nextY, buttonSize, nextHover, scale, controlIconScale, HudRenderUtil.scaleAlpha(uiText, scale)); + + if (showRepeat) { + drawRepeatControl(renderer, snapshot, repeatX, repeatY, buttonSize, repeatHover, scale); + } + } + + private void drawControl(Renderer2D renderer, + TextRenderer iconRenderer, + String icon, + float x, + float y, + float size, + float hover, + float alpha, + float iconScale, + int iconColor) { + if (size <= 0f) return; + float radius = size * 0.5f; + float hoverEase = clamp01(hover); + int bg = HudRenderUtil.scaleAlpha(uiAccentSoft, hoverEase * 0.45f * alpha); + if (hoverEase > 0.01f) { + renderer.roundedRect(x, y, size, size, radius, 1.0f, bg); + } + + iconRenderer.begin(iconScale * alpha, false, false); + float iw = (float) iconRenderer.getWidth(icon, false); + float ih = (float) iconRenderer.getHeight(false); + float ix = x + (size - iw) * 0.5f; + float iy = y + (size - ih) * 0.5f; + iconRenderer.render(icon, ix, iy, new RenderColor(iconColor), false); + iconRenderer.end(); + } + + private void drawRepeatControl(Renderer2D renderer, + MediaSessionService.Snapshot snapshot, + float x, + float y, + float size, + float hover, + float alpha) { + if (size <= 0f) return; + float radius = size * 0.5f; + float hoverEase = clamp01(hover); + int bg = HudRenderUtil.scaleAlpha(uiAccentSoft, hoverEase * 0.45f * alpha); + if (hoverEase > 0.01f) { + renderer.roundedRect(x, y, size, size, radius, 1.0f, bg); + } + + String icon = repeatSvg(snapshot); + int color = repeatColor(snapshot, alpha); + float iconSize = size * 0.78f; + float ix = x + (size - iconSize) * 0.5f; + float iy = y + (size - iconSize) * 0.5f; + renderer.svg(icon, ix, iy, iconSize, iconSize, SvgRenderOptions.overrideColor(color)); + } + + private void updateHover(boolean chatOpen, boolean showShuffle, boolean showRepeat) { + if (!chatOpen) { + playHover = lerp(playHover, 0f, 0.2f); + shuffleHover = lerp(shuffleHover, 0f, 0.2f); + prevHover = lerp(prevHover, 0f, 0.2f); + nextHover = lerp(nextHover, 0f, 0.2f); + repeatHover = lerp(repeatHover, 0f, 0.2f); + return; + } + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + float uiScale = HudScale.scale(fbw, fbh); + float mx = HudScale.toVirtual((float) mc.mouseHandler.xpos(), uiScale); + float my = HudScale.toVirtual((float) mc.mouseHandler.ypos(), uiScale); + shuffleHover = lerp(shuffleHover, showShuffle && hit(mx, my, shuffleX, shuffleY, buttonSize, buttonSize) ? 1f : 0f, 0.25f); + playHover = lerp(playHover, hit(mx, my, playX, playY, buttonSize, buttonSize) ? 1f : 0f, 0.25f); + prevHover = lerp(prevHover, hit(mx, my, prevX, prevY, buttonSize, buttonSize) ? 1f : 0f, 0.25f); + nextHover = lerp(nextHover, hit(mx, my, nextX, nextY, buttonSize, buttonSize) ? 1f : 0f, 0.25f); + repeatHover = lerp(repeatHover, showRepeat && hit(mx, my, repeatX, repeatY, buttonSize, buttonSize) ? 1f : 0f, 0.25f); + } + + private void updateButtons() { + shuffleX = (float) 0.0; + shuffleY = (float) 0.0; + playX = (float) 0.0; + playY = (float) 0.0; + prevX = (float) 0.0; + prevY = (float) 0.0; + nextX = (float) 0.0; + nextY = (float) 0.0; + repeatX = (float) 0.0; + repeatY = (float) 0.0; + buttonSize = (float) 0.0; + playHover = 0f; + shuffleHover = 0f; + prevHover = 0f; + nextHover = 0f; + repeatHover = 0f; + } + + private int shuffleColor(MediaSessionService.Snapshot snapshot, float alpha) { + if (snapshot == null || !snapshot.supportsShuffle()) { + return HudRenderUtil.scaleAlpha(0xFF000000, 0.36f * alpha); + } + return snapshot.isShuffleActive() + ? HudRenderUtil.scaleAlpha(0xFFFFFFFF, alpha) + : HudRenderUtil.scaleAlpha(0xFFB7BEC8, 0.48f * alpha); + } + + private int repeatColor(MediaSessionService.Snapshot snapshot, float alpha) { + if (snapshot == null || !snapshot.supportsRepeat()) { + return HudRenderUtil.scaleAlpha(0xFF000000, 0.36f * alpha); + } + RepeatMode mode = snapshot.repeatMode(); + return mode == RepeatMode.ALL || mode == RepeatMode.ONE + ? HudRenderUtil.scaleAlpha(0xFFFFFFFF, alpha) + : HudRenderUtil.scaleAlpha(0xFFB7BEC8, 0.48f * alpha); + } + + private String repeatSvg(MediaSessionService.Snapshot snapshot) { + if (snapshot == null || !snapshot.supportsRepeat()) { + return "repeat-off"; + } + return snapshot.repeatMode() == RepeatMode.ONE ? "repeat-1" : "repeat"; + } + + private String formatTime(long seconds) { + long sec = Math.max(0L, seconds); + long m = sec / 60L; + long s = sec % 60L; + return String.format("%d:%02d", m, s); + } + + private String safe(String value) { + if (value == null) return ""; + String v = value.trim(); + return v.isEmpty() ? "" : v; + } + + private float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + private boolean hit(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + private float clamp01(float v) { + return Math.max(0f, Math.min(1f, v)); + } + + private void drawScrollingTitle(TextRenderer renderer, + String text, + float x, + float y, + float fullWidth, + float viewWidth, + float scale, + float baseScale, + float alpha) { + float speed = BASE_TITLE_SCROLL_SPEED * baseScale; + float gap = BASE_TITLE_SCROLL_GAP * baseScale; + if (speed <= 0f) return; + float cycleDistance = fullWidth + gap; + float cycleTime = TITLE_SCROLL_PAUSE_SEC + (cycleDistance / speed); + if (cycleTime <= 0f) return; + + float now = Util.getMillis() / 1000f; + float t = now % cycleTime; + float offset = 0f; + if (t > TITLE_SCROLL_PAUSE_SEC) { + offset = -(t - TITLE_SCROLL_PAUSE_SEC) * speed; + } + + int color = HudRenderUtil.scaleAlpha(uiText, alpha); + RenderColor renderColor = new RenderColor(color); + float fade = Math.min(viewWidth * 0.25f, BASE_TITLE_FADE * baseScale); + renderer.begin(scale, false, false); + try { + renderer.renderHorizontalFadeClipped(text, x + offset, y, renderColor, x, x + viewWidth, fade, fade, false); + if (cycleDistance > viewWidth * 0.5f) { + renderer.renderHorizontalFadeClipped(text, x + offset + cycleDistance, y, renderColor, + x, x + viewWidth, fade, fade, false); + } + } finally { + renderer.end(); + } + } + + private float getTextHeight(TextRenderer renderer, float scale) { + renderer.begin(scale, false, false); + try { + return (float) renderer.getHeight(false); + } finally { + renderer.end(); + } + } + + private String trimText(TextRenderer tr, String text, float maxWidth, float scale) { + if (text == null || text.isEmpty()) return ""; + tr.begin(scale, false, false); + try { + if (tr.getWidth(text, false) <= maxWidth) return text; + String value = text; + while (!value.isEmpty()) { + String candidate = value + "..."; + if (tr.getWidth(candidate, false) <= maxWidth) return candidate; + value = value.substring(0, value.length() - 1); + } + return ""; + } finally { + tr.end(); + } + } + + private void drawBlur(float x, float y, float w, float h, float radius, int tintRgb) { + float quality = hud.getBlurRadius(); + float brightness = 1.0f; + float alpha = blurAlpha.get() / 255f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, brightness, alpha, 0xFFFFFF); + } + + private void drawGlass(float x, float y, float w, float h, float radius, float scale, int accentSoft) { + float alpha = blurAlpha.get() / 255f; + HudRenderUtil.drawLiquidGlass(x, y, w, h, radius, scale, true, alpha); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private boolean isBlurEffect() { + return EFFECT_BLUR.equals(bgEffect.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private int getBgAlpha() { + if (syncTheme.get()) { + return bgAlpha.get(); + } + return (bg.getArgb() >>> 24) & 0xFF; + } + @Override + public void collectRenderPrewarm(RenderPrewarmCollectEvent event) { + super.collectRenderPrewarm(event); + event.font("MediaPlayer", FontInfo.Type.Regular) + .font("Inter", FontInfo.Type.Bold) + .font("InterMedium", FontInfo.Type.Regular) + .svg("repeat") + .svg("repeat-1") + .svg("repeat-off"); + } + +} + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Memory.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Memory.java new file mode 100644 index 0000000..68693c2 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Memory.java @@ -0,0 +1,301 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "memory", + displayName = "Memory", + enabledByDefault = true, + order = 130 +) +public final class Memory extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier MEMORY_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/memory.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 11f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float VALUE_UNIT_GAP = 2f; + private static final float EXTRA_GAP = 5f; + private static final float RIGHT_PAD = 5f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final NumberValue scale = num("memory_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("memory_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("memory_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("memory_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue metaColor = visibleWhen(colorNoAlpha("memory_meta_color", "#9B9B9B"), this::isCustomMode); + private final RGBColorValue extraColor = visibleWhen(colorNoAlpha("memory_extra_color", "#BEBEBE"), this::isCustomMode); + private final ModeValue bgEffect = mode("memory_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("memory_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("memory_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("memory_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("memory_bg_alpha", 135, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("memory_blur_alpha", 255, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("memory_label_effect", HudTextEffects.Effect.NONE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("memory_label_effect_speed", 18, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("memory"); + private final StringBuilder valueBuilder = new StringBuilder(24); + private long lastUsedMiB = Long.MIN_VALUE; + private long lastMaxMiB = Long.MIN_VALUE; + private int lastPercent = Integer.MIN_VALUE; + private String cachedValue = "0/0"; + private String cachedExtra = "[0%]"; + private float displayValueWidth = -1f; + private float displayExtraWidth = -1f; + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private int uiMetaColor; + private int uiExtraColor; + + private static long toMiB(long bytes) { + return bytes / 1024L / 1024L; + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 44f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + Runtime rt = Runtime.getRuntime(); + long max = rt.maxMemory(); + long total = rt.totalMemory(); + long free = rt.freeMemory(); + long used = Math.max(0L, total - free); + long usedMiB = toMiB(used); + long maxMiB = toMiB(max > 0L ? max : total); + int percent = max > 0L ? (int) Math.round((double) used * 100.0 / (double) max) : 0; + percent = Math.max(0, Math.min(100, percent)); + updateMemoryStrings(usedMiB, maxMiB, percent); + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + TextRenderer metaRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, valueRenderer); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + valueRenderer.begin(drawScale, true, false); + float valueW = (float) valueRenderer.getWidth(cachedValue, false); + float valueH = (float) valueRenderer.getHeight(false); + valueRenderer.end(); + + metaRenderer.begin(drawScale, true, false); + float unitW = (float) metaRenderer.getWidth("MB", false); + float unitH = (float) metaRenderer.getHeight(false); + float extraW = (float) metaRenderer.getWidth(cachedExtra, false); + metaRenderer.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconSize = ICON_SIZE * drawScale; + float valueX = x + TEXT_LEFT_X * drawScale; + float unitGap = VALUE_UNIT_GAP * drawScale; + float extraGap = EXTRA_GAP * drawScale; + displayValueWidth = smoothWidth(displayValueWidth, valueW); + displayExtraWidth = smoothWidth(displayExtraWidth, extraW); + float boxW = (valueX - x) + + displayValueWidth + + unitGap + + unitW + + extraGap + + displayExtraWidth + + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - Math.max(valueH, unitH)) * 0.5f; + float unitX = valueX + displayValueWidth + unitGap; + float extraX = unitX + unitW + extraGap; + + int resolvedIconColor = uiIconColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(MEMORY_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiMetaColor, 0x52)); + scriptModel.value().set(cachedValue, "Onest", drawScale, valueX - baseX, textY - baseY, displayValueWidth, uiValueColor); + scriptModel.unit().set("MB", "OnestMedium", drawScale, unitX - baseX, textY - baseY, unitW, uiMetaColor); + scriptModel.extra().set(cachedExtra, "OnestMedium", drawScale, extraX - baseX, textY - baseY, displayExtraWidth, uiExtraColor); + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("usedMiB", usedMiB) + .data("maxMiB", maxMiB) + .data("percent", percent) + .data("valueText", cachedValue) + .data("unitText", "MB") + .data("extraText", cachedExtra); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private void updateMemoryStrings(long usedMiB, long maxMiB, int percent) { + if (usedMiB == lastUsedMiB && maxMiB == lastMaxMiB && percent == lastPercent) { + return; + } + lastUsedMiB = usedMiB; + lastMaxMiB = maxMiB; + lastPercent = percent; + + valueBuilder.setLength(0); + valueBuilder.append(usedMiB).append('/').append(maxMiB); + cachedValue = valueBuilder.toString(); + cachedExtra = "[" + percent + "%]"; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + uiMetaColor = theme().textMuted(); + uiExtraColor = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.18f); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiMetaColor = metaColor.getArgb(); + uiExtraColor = extraColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ModuleList.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ModuleList.java new file mode 100644 index 0000000..2c96b1c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ModuleList.java @@ -0,0 +1,499 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.util.Util; +import dev.loki.lovisual.config.ConfigValueAliasProvider; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.config.values.RGBAColorValue; +import dev.loki.lovisual.config.values.RGBColorValue; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.module.Module; +import dev.loki.lovisual.features.module.ModuleManager; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 10) +public final class ModuleList extends DraggableHudElement implements ConfigValueAliasProvider { + + private static final float BASE_LINE_H = 9f; + private static final float BASE_TEXT_Y = 3f; + private static final float BASE_TEXT_X = 3f; + private static final float BASE_TEXT_X_REV = 2f; + private static final float BASE_STRING_PAD = 3f; + private static final float BASE_RECT_EXTRA = 1f; + private static final float BASE_RADIUS = 2f; + private static final float BASE_SOFTNESS = 0.55f; + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final NumberValue scaleValue = + new NumberValue<>("module_list_scale", 3.67, 0.5, 5.0); + private final EnumValue colorMode = + new EnumValue<>("module_list_color_mode", ColorMode.CUSTOM, ColorMode.THEME, ColorMode.CUSTOM); + private final EnumValue bgEffect = + new EnumValue<>("module_list_bg_effect", BgEffect.BLUR, BgEffect.NONE, BgEffect.BLUR, BgEffect.GLASS); + private final NumberValue bgAlpha = + new NumberValue<>("module_list_bg_alpha", 226, 0, 255); + private final NumberValue blurAlpha = + new NumberValue<>("module_list_blur_alpha", 255, 0, 255); + private final RGBAColorValue bgTopColor = + new RGBAColorValue("module_list_bg", "#FF444242"); + private final RGBAColorValue bgBottomColor = + new RGBAColorValue("module_list_bg_secondary", "#22000000"); + private final RGBColorValue textTopColor = + new RGBColorValue("module_list_text", "RNB#E87171"); + private final RGBColorValue textBottomColor = + new RGBColorValue("module_list_text_secondary", "#93118C"); + private final EnumValue textGradientMode = + new EnumValue<>("module_list_text_gradient_mode", + AnimatedRenderColors.Mode.RAINBOW, + AnimatedRenderColors.Mode.STATIC, + AnimatedRenderColors.Mode.RAINBOW, + AnimatedRenderColors.Mode.LIGHT_RAINBOW, + AnimatedRenderColors.Mode.SKY, + AnimatedRenderColors.Mode.FADE, + AnimatedRenderColors.Mode.DOUBLE_COLOR, + AnimatedRenderColors.Mode.ANALOGOUS, + AnimatedRenderColors.Mode.THEME); + private final NumberValue textGradientSpeed = + new NumberValue<>("module_list_text_gradient_speed", 18, 2, 54); + private final EnumValue textEffect = + new EnumValue<>("module_list_text_effect", TextEffect.NONE, TextEffect.NONE, TextEffect.SHIMMER); + private final NumberValue textEffectSpeed = + new NumberValue<>("module_list_text_effect_speed", 30, 1, 60); + private final List rows = new ArrayList<>(); + private final List enabledModules = new ArrayList<>(); + private float renderX; + private float renderY; + private int cachedModulesHash; + private int cachedModulesCount; + private float cachedTextScale = -1f; + public ModuleList() { + super("module_list", "ModuleList", false); + } + + @Override + public List getLegacyValueNames(String currentValueName) { + if (currentValueName == null) { + return List.of(); + } + if (currentValueName.startsWith("module_list_")) { + return List.of("active_modules_" + currentValueName.substring("module_list_".length())); + } + return List.of(); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.color(bgTopColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bgBottomColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(textTopColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(textBottomColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(textGradientMode)); + defs.add(SettingDef.number(textGradientSpeed).visibleWhen(this::hasAnimatedTextGradient)); + defs.add(SettingDef.mode(textEffect)); + defs.add(SettingDef.number(textEffectSpeed).visibleWhen(() -> textEffect.get() != TextEffect.NONE)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 80f; + this.y = 20f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + if (!isEnabled() || mc == null || mc.player == null) { + rows.clear(); + width = 0f; + height = 0f; + return; + } + + TextRenderer listRenderer = Fonts.renderer("Comfortaa", FontInfo.Type.Regular, TextRenderer.get()); + if (listRenderer == null) { + listRenderer = textRenderer; + } + + float baseScale = HudScale.scale(screenW, screenH) * scaleValue.get().floatValue(); + float lineH = BASE_LINE_H * baseScale; + float textOffsetY = BASE_TEXT_Y * baseScale; + float textOffsetX = BASE_TEXT_X * baseScale; + float textOffsetXRev = BASE_TEXT_X_REV * baseScale; + float stringPad = BASE_STRING_PAD * baseScale; + float rectExtra = BASE_RECT_EXTRA * baseScale; + float radius = BASE_RADIUS * baseScale; + float softness = BASE_SOFTNESS * baseScale; + + float fontHeight = Math.max(1.0f, (float) listRenderer.getHeight(false)); + float targetTextHeight = Math.max(4.4f * baseScale, lineH - 4.2f * baseScale); + float textScale = Math.max(0.2f, targetTextHeight / fontHeight); + + enabledModules.clear(); + int modulesHash = 1; + for (Module module : ModuleManager.getModules()) { + if (module != null && module.isEnabled() && module.isShownInModuleList()) { + enabledModules.add(module); + String label = module.getDisplayName(); + modulesHash = 31 * modulesHash + System.identityHashCode(module); + modulesHash = 31 * modulesHash + (label != null ? label.hashCode() : 0); + } + } + + if (enabledModules.isEmpty()) { + rows.clear(); + width = 0f; + height = 0f; + return; + } + + boolean layoutChanged = modulesHash != cachedModulesHash + || enabledModules.size() != cachedModulesCount + || Math.abs(textScale - cachedTextScale) > 0.0001f; + + if (layoutChanged) { + rows.clear(); + listRenderer.begin(textScale, false, false); + for (Module module : enabledModules) { + Row row = new Row(module, module.getDisplayName()); + row.textWidth = (float) listRenderer.getWidth(row.label, false); + rows.add(row); + } + listRenderer.end(); + rows.sort(Comparator.comparingDouble((Row row) -> row.textWidth).reversed()); + cachedModulesHash = modulesHash; + cachedModulesCount = enabledModules.size(); + cachedTextScale = textScale; + } + + float maxWidth = 0f; + for (Row row : rows) { + maxWidth = Math.max(maxWidth, row.textWidth + stringPad + rectExtra); + } + + float maxX = Math.max(0f, screenW - maxWidth); + if (x > maxX) x = maxX; + if (x < 0f) x = 0f; + + boolean reverse = x > (screenW * 0.5f); + float reversedX = x; + float drawBaseX = reverse ? (reversedX + maxWidth) : x; + float listHeight = rows.size() * lineH; + + renderX = reverse ? (drawBaseX - maxWidth) : drawBaseX; + renderY = y; + width = maxWidth; + height = listHeight; + + int bgTop = resolveBgTop(); + int bgBottom = resolveBgBottom(); + int textTop = resolveTextTop(); + int textBottom = resolveTextBottom(); + boolean blur = isBlurEffect(); + boolean glass = isGlassEffect(); + boolean shimmer = textEffect.get() == TextEffect.SHIMMER; + float time = (float) (Util.getMillis() / 1000.0); + float blurQuality = hud != null ? hud.getBlurRadius() : 12.0f; + boolean autoBatch = !Renderer2D.isBatching(); + if (autoBatch) renderer.begin(); + try { + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + float offsetY = y + i * lineH; + float stringWidth = row.textWidth + stringPad; + float rowX = reverse ? (drawBaseX - stringWidth) : drawBaseX; + float rowW = stringWidth + rectExtra; + + row.x = rowX; + row.y = offsetY; + row.w = rowW; + row.fillTop = rowFill(bgTop, i, rows.size()); + row.fillBottom = rowFill(bgBottom, i, rows.size()); + } + + if (blur) { + renderer.blurComposite(composite -> { + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + int tint = HudRenderUtil.mixColor(row.fillTop, row.fillBottom, 0.5f) & 0x00FFFFFF; + composite.roundedRect(row.x, row.y, row.w, lineH, radius, blurQuality, 1.0f, blurAlpha.get() / 255f, tint); + } + }); + } else if (glass) { + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + drawGlass(row.x, row.y, row.w, lineH, radius); + } + } + + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + if (glass) { + int glassTop = HudRenderUtil.scaleAlpha(row.fillTop, 0.22f); + int glassBottom = HudRenderUtil.scaleAlpha(row.fillBottom, 0.18f); + renderer.roundedRectGradientQuad(row.x, row.y, row.w, lineH, radius, softness, + glassTop, glassTop, glassBottom, glassBottom); + } else { + int drawTop = blur ? HudRenderUtil.scaleAlpha(row.fillTop, 0.42f) : row.fillTop; + int drawBottom = blur ? HudRenderUtil.scaleAlpha(row.fillBottom, 0.42f) : row.fillBottom; + renderer.roundedRectGradientQuad(row.x, row.y, row.w, lineH, radius, softness, + drawTop, drawTop, drawBottom, drawBottom); + } + } + } finally { + if (autoBatch) renderer.render(); + } + + int gradientTop = resolveTextGradientTop(); + int gradientBottom = resolveTextGradientBottom(); + boolean topRainbow = isCustomMode() && textTopColor.isRainbow(); + boolean bottomRainbow = isCustomMode() && textBottomColor.isRainbow(); + AnimatedRenderColors.Mode gradientMode = textGradientMode.get(); + int gradientSpeed = hasAnimatedTextGradient() ? textGradientSpeed.get() : Math.max(1, textEffectSpeed.get()); + + listRenderer.begin(textScale, false, false); + float textHeight = (float) listRenderer.getHeight(false); + float blockTop = renderY; + float blockBottom = renderY + height; + for (Row row : rows) { + float textX = reverse ? (drawBaseX - (row.textWidth + stringPad) + textOffsetXRev) : (drawBaseX + textOffsetX); + float textY = row.y + textOffsetY + (lineH - textHeight - textOffsetY) * 0.08f; + listRenderer.renderQuadGradient(row.label, textX, textY, (idx, cp, x0, y0, x1, y1, out) -> + AnimatedRenderColors.moduleListBlockGlyphGradient( + gradientTop, + gradientBottom, + gradientMode, + gradientSpeed, + y0, + y1, + blockTop, + blockBottom, + idx, + time, + topRainbow, + bottomRainbow, + shimmer, + out + ), false); + } + listRenderer.end(); + } + + @Override + public boolean contains(float mx, float my) { + return mx >= renderX && mx <= renderX + width + && my >= renderY && my <= renderY + height; + } + + private int resolveBgTop() { + if (isThemeMode()) { + int base = HudRenderUtil.mixColor(theme().windowBg(), 0xFF000000, 0.38f); + return HudRenderUtil.setAlpha(base, bgAlpha.get()); + } + return bgTopColor.getArgb(); + } + + private int resolveBgBottom() { + if (isThemeMode()) { + int base = HudRenderUtil.mixColor(theme().windowHeader(), 0xFF000000, 0.48f); + return HudRenderUtil.setAlpha(base, bgAlpha.get()); + } + return bgBottomColor.getArgb(); + } + + private int resolveTextTop() { + if (isThemeMode()) { + return HudRenderUtil.mixColor(theme().accentSoft(), theme().accent(), 0.18f); + } + return textTopColor.getArgb(); + } + + private int resolveTextBottom() { + if (isThemeMode()) { + return theme().accent(); + } + return textBottomColor.getArgb(); + } + + private int resolveTextGradientTop() { + if (isThemeMode()) { + return resolveTextTop(); + } + return textTopColor.isRainbow() ? parseRgbFallback(textTopColor.rainbowFallbackHex()) : textTopColor.getArgb(); + } + + private int resolveTextGradientBottom() { + if (isThemeMode()) { + return resolveTextBottom(); + } + return textBottomColor.isRainbow() ? parseRgbFallback(textBottomColor.rainbowFallbackHex()) : textBottomColor.getArgb(); + } + + private int parseRgbFallback(String hex) { + if (hex == null || hex.isBlank()) return 0xFFFFFFFF; + String s = hex.trim(); + if (s.startsWith("#")) s = s.substring(1); + if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2); + try { + int value = (int) Long.parseUnsignedLong(s, 16); + if (s.length() <= 6) return 0xFF000000 | (value & 0x00FFFFFF); + return value; + } catch (NumberFormatException ignored) { + return 0xFFFFFFFF; + } + } + + private int rowFill(int argb, int index, int total) { + if (!isThemeMode() || total <= 1) { + return argb; + } + float progress = (float) index / (float) (total - 1); + int alpha = (argb >>> 24) & 0xFF; + int rgb = HudRenderUtil.mixColor(argb, resolveTextBottom(), 0.03f + 0.07f * progress); + return (rgb & 0x00FFFFFF) | (alpha << 24); + } + + private void drawBlur(float x, float y, float w, float h, float radius, int tintArgb, float quality) { + float alpha = blurAlpha.get() / 255f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, 1.0f, alpha, 0xFFFFFF); + } + + private void drawGlass(float x, float y, float w, float h, float radius) { + float blurStrength = blurAlpha.get() / 255f; + if (blurStrength <= 0.001f) return; + Renderer2D.COLOR.liquidGlassRect( + x, y, w, h, radius, + 0xFFFFFFFF, + 1.0f, + blurStrength, + Renderer2D.LiquidGlassPreset.BALANCED + ); + } + + private boolean isThemeMode() { + return colorMode.get() == ColorMode.THEME; + } + + private boolean isCustomMode() { + return colorMode.get() == ColorMode.CUSTOM; + } + + private boolean isBlurEffect() { + return bgEffect.get() == BgEffect.BLUR; + } + + private boolean isGlassEffect() { + return bgEffect.get() == BgEffect.GLASS; + } + + private boolean hasEffect() { + return bgEffect.get() != BgEffect.NONE; + } + + private boolean hasAnimatedTextGradient() { + return textGradientMode.get() != AnimatedRenderColors.Mode.STATIC + || (isCustomMode() && (textTopColor.isRainbow() || textBottomColor.isRainbow())); + } + + private enum ColorMode implements EnumValue.IdProvider { + THEME("Theme"), + CUSTOM("Custom"); + + private final String id; + + ColorMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum BgEffect implements EnumValue.IdProvider { + NONE("None"), + BLUR("Blur"), + GLASS("Glass"); + + private final String id; + + BgEffect(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum TextEffect implements EnumValue.IdProvider { + NONE("None"), + SHIMMER("Shimmer"); + + private final String id; + + TextEffect(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private static final class Row { + private final Module module; + private final String label; + private float textWidth; + private float x; + private float y; + private float w; + private int fillTop; + private int fillBottom; + + private Row(Module module, String label) { + this.module = module; + this.label = label; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Ping.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Ping.java new file mode 100644 index 0000000..e646e04 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Ping.java @@ -0,0 +1,263 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.player.NetworkStatsUtil; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +//todo Description +@HudElementInfo( + id = "ping", + displayName = "Ping", + enabledByDefault = false, + order = 150 +) +public final class Ping extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier PING_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/ping.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 11f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float VALUE_UNIT_GAP = 2f; + private static final float RIGHT_PAD = 5f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final NumberValue scale = num("ping_scale", 2.58, 0.5, 5.0); + private final ModeValue colorMode = mode("ping_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("ping_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("ping_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue metaColor = visibleWhen(colorNoAlpha("ping_meta_color", "#9B9B9B"), this::isCustomMode); + private final ModeValue bgEffect = mode("ping_bg_effect", "bg_effect", "None", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("ping_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("ping_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("ping_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("ping_bg_alpha", 225, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("ping_blur_alpha", 140, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("ping_label_effect", HudTextEffects.Effect.NONE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("ping_label_effect_speed", 18, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("ping"); + + private int lastPing = Integer.MIN_VALUE; + private String cachedValue = "--"; + private float displayValueWidth = -1f; + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private int uiMetaColor; + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 76f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + int ping = NetworkStatsUtil.getPing(mc); + String value = getPingValue(ping); + String unit = "ms"; + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + TextRenderer metaRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, valueRenderer); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + valueRenderer.begin(drawScale, true, false); + float valueW = (float) valueRenderer.getWidth(value, false); + float valueH = (float) valueRenderer.getHeight(false); + valueRenderer.end(); + + metaRenderer.begin(drawScale, true, false); + float unitW = (float) metaRenderer.getWidth(unit, false); + float unitH = (float) metaRenderer.getHeight(false); + metaRenderer.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconSize = ICON_SIZE * drawScale; + float valueX = x + TEXT_LEFT_X * drawScale; + float unitGap = VALUE_UNIT_GAP * drawScale; + displayValueWidth = smoothWidth(displayValueWidth, valueW); + float boxW = (valueX - x) + displayValueWidth + unitGap + unitW + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - Math.max(valueH, unitH)) * 0.5f; + float unitX = valueX + displayValueWidth + unitGap; + + int resolvedIconColor = uiIconColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(PING_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiMetaColor, 0x52)); + scriptModel.value().set(value, "Onest", drawScale, valueX - baseX, textY - baseY, displayValueWidth, uiValueColor); + scriptModel.unit().set(unit, "OnestMedium", drawScale, unitX - baseX, textY - baseY, unitW, uiMetaColor); + scriptModel.extra().hidden(); + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("ping", ping) + .data("valueText", value) + .data("unitText", unit); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private String getPingValue(int ping) { + if (ping == lastPing) return cachedValue; + lastPing = ping; + cachedValue = ping >= 0 ? Integer.toString(ping) : "--"; + return cachedValue; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + uiMetaColor = theme().textMuted(); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiMetaColor = metaColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Potions.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Potions.java new file mode 100644 index 0000000..b08388a --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Potions.java @@ -0,0 +1,617 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +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.resources.language.I18n; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.Util; +import net.minecraft.world.effect.MobEffectCategory; +import net.minecraft.world.effect.MobEffectInstance; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 20) +public final class Potions extends DraggableHudElement { + + { + defaultLayout(31.729004f, 182.0f); + } + + + private static final float HEADER_HEIGHT = 15.5f; + private static final float BODY_Y_OFFSET = 18.5f; + private static final float CONTENT_START_Y = 25.0f; + private static final float BODY_INSET_Y = 6.5f; + private static final float ROW_STEP = 11.0f; + private static final float MIN_WIDTH = 95.0f; + private static final float PANEL_RADIUS = 4.0f; + private static final float PANEL_SOFTNESS = 1.0f; + private static final float PANEL_STROKE = 0.55f; + private static final float HEADER_DIVIDER_X = 18.0f; + private static final float HEADER_DIVIDER_Y = 5.0f; + private static final float HEADER_DIVIDER_H = 6.0f; + private static final float ROW_DIVIDER_X = 14.0f; + private static final float ROW_DIVIDER_H = 7.0f; + private static final float TITLE_ICON_X = 5.0f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float COUNT_LABEL_OFFSET = 22.0f; + private static final float COUNT_VALUE_OFFSET = 3.0f; + private static final float ROW_ICON_X = 3.5f; + private static final float ROW_ICON_SIZE = 8.0f; + private static final float ROW_TEXT_X = 18.0f; + private static final float ROW_TIME_RIGHT = 8.0f; + private static final float ROW_CONTENT_CENTER_OFFSET = 2.1f; + private static final int BLINK_THRESHOLD_TICKS = 200; + private static final double BLINK_SPEED = 0.009; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final int NEGATIVE_EFFECT_RGB = 0xFFFF554B; + private static final Identifier[] PREVIEW_ICONS = new Identifier[]{ + Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/speed.png"), + Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/haste.png"), + Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/fire_resistance.png"), + Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/regeneration.png"), + Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/night_vision.png") + }; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedListHudPanel scriptedPanel = new ScriptedListHudPanel(); + private final NumberValue scaleValue = + new NumberValue<>("potions_scale", 1.05, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX); + private final ModeValue layoutMode = + new ModeValue("potions_layout", "Unified Divider", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER); + private final ModeValue colorMode = + new ModeValue("potions_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("potions_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final RGBAColorValue bg = + new RGBAColorValue("potions_bg", "#EB784F4F"); + private final RGBAColorValue bg2 = + new RGBAColorValue("potions_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("potions_bg_alpha", 200, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("potions_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("potions_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("potions_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("potions_blur_alpha", 159, 0, 255); + private final BooleanValue headerIconPulse = + new BooleanValue("potions_header_icon_pulse", true); + private final NumberValue headerIconPulseSpeed = + new NumberValue<>("potions_header_icon_pulse_speed", 18, 1, 60); + private final NumberValue headerIconPulseIntensity = + new NumberValue<>("potions_header_icon_pulse_intensity", 100, 0, 100); + + private final Map entryAnim = new LinkedHashMap<>(); + private final Map lastEntries = new LinkedHashMap<>(); + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiAmpText; + private int uiDivider; + private int uiBlurTint; + + public Potions() { + super("potions", "Potions", true); + } + + private static int clamp255(int value) { + return Math.max(0, Math.min(255, value)); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(layoutMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(headerIconPulse)); + defs.add(SettingDef.number(headerIconPulseSpeed) + .visibleWhen(headerIconPulse::get)); + defs.add(SettingDef.number(headerIconPulseIntensity) + .visibleWhen(headerIconPulse::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 120.0f; + this.y = 20.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean forceVisible = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !forceVisible)) { + width = 0.0f; + height = 0.0f; + return; + } + + boolean chatPreview = ClientScreen.current() instanceof ChatScreen; + List rows = collectRows(); + List animatedRows = animateRows(rows); + boolean showExampleRow = rows.isEmpty() && (forceVisible || chatPreview); + boolean showWidget = !rows.isEmpty() || showExampleRow; + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, showWidget); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + if (animatedRows.isEmpty() && !showExampleRow && visibilityAnim <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + TextRenderer headerIconRenderer = Fonts.renderer("IconsNur", FontInfo.Type.Regular, TextRenderer.get()); + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer rowTextRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (rowTextRenderer == null) rowTextRenderer = textRenderer; + if (headerIconRenderer == null) headerIconRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(scaleValue); + float rowStep = ROW_STEP * baseScale; + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + + headerIconRenderer.begin(fontScale, true, false); + float headerIconH = (float) headerIconRenderer.getHeight(false); + headerIconRenderer.end(); + + headerTextRenderer.begin(fontScale, true, false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Potions", false); + headerTextRenderer.end(); + + rowTextRenderer.begin(fontScale, true, false); + float rowTextH = (float) rowTextRenderer.getHeight(false); + float maxWidth = MIN_WIDTH * baseScale; + if (showExampleRow) { + float previewWidth = (float) rowTextRenderer.getWidth("Example effect**:**", false) + (30.0f * baseScale); + maxWidth = Math.max(maxWidth, previewWidth); + } else { + for (AnimatedRow animatedRow : animatedRows) { + Row row = animatedRow.row(); + String label = row.name() + (row.amp().isEmpty() ? "" : " " + row.amp()); + float widthCandidate = (float) rowTextRenderer.getWidth(label + row.duration(), false) + (30.0f * baseScale); + maxWidth = Math.max(maxWidth, widthCandidate); + } + } + rowTextRenderer.end(); + + rowTextRenderer.begin(fontScale * 0.92f, false, false); + String activeCountText = Integer.toString(rows.size()); + float activeLabelWidth = (float) rowTextRenderer.getWidth("Active:", false); + float activeCountWidth = (float) rowTextRenderer.getWidth(activeCountText, false); + rowTextRenderer.end(); + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + activeLabelWidth + activeCountWidth + + ((COUNT_LABEL_OFFSET + 12.0f) * baseScale); + maxWidth = Math.max(maxWidth, headerWidth); + + float contentHeight = showExampleRow ? rowStep : totalAnimatedHeight(animatedRows, rowStep); + float targetHeight = (showExampleRow || !animatedRows.isEmpty()) + ? (CONTENT_START_Y * baseScale + contentHeight) + : (HEADER_HEIGHT * baseScale); + + displayWidth = HudRenderUtil.animateDimension(displayWidth, maxWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + if (!showWidget && widgetScale <= 0.001f && animatedRows.isEmpty()) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderIconH = headerIconH * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawRowTextH = rowTextH * drawScale; + int resolvedHeaderIconColor = uiCounter; + if (headerIconPulse.get()) { + int pulsedHeaderIconColor = HudTextEffects.animatedColor( + uiCounter, + HudTextEffects.Effect.PULSE, + headerIconPulseSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + resolvedHeaderIconColor = HudRenderUtil.mixColor( + uiCounter, + pulsedHeaderIconColor, + headerIconPulseIntensity.get() / 100.0f + ); + } + + List> panelRows = ScriptedListHudPanel.rows(); + rowTextRenderer.begin(drawFontScale, false, false); + if (showExampleRow) { + Identifier previewIcon = PREVIEW_ICONS[(int) ((System.currentTimeMillis() / 1000L) % PREVIEW_ICONS.length)]; + List> parts = List.of( + ScriptedListHudPanel.textPart("Example effect", withAlpha(uiText & 0x00FFFFFF, 255), 0.0f) + ); + LinkedHashMap row = ScriptedListHudPanel.row( + "preview", + "texture", + ScriptedListHudPanel.idString(previewIcon), + parts, + "**:**", + withAlpha(uiCounter & 0x00FFFFFF, 255), + withAlpha(uiMuted & 0x00FFFFFF, 130), + 1.0f + ); + row.put("iconTint", ScriptedListHudPanel.hex(withAlpha(0x00FFFFFF, 255))); + panelRows.add(row); + } else { + for (AnimatedRow animatedRow : animatedRows) { + float anim = animatedRow.anim(); + if (anim <= 0.01f) continue; + Row rowData = animatedRow.row(); + int rowBaseAlpha = clamp255(Math.round(255.0f * anim)); + int blinkAlpha = getBlinkAlpha(rowData, rowBaseAlpha, 100); + boolean bad = isBadEffect(rowData.effect()); + int nameColor = withAlpha(bad ? NEGATIVE_EFFECT_RGB : (uiText & 0x00FFFFFF), blinkAlpha); + int dividerColor = withAlpha((bad ? NEGATIVE_EFFECT_RGB : (uiMuted & 0x00FFFFFF)), Math.max(24, blinkAlpha - 125)); + List> parts = new ArrayList<>(2); + parts.add(ScriptedListHudPanel.textPart(rowData.name(), nameColor, 0.0f)); + if (!rowData.amp().isEmpty()) { + float labelWidth = (float) rowTextRenderer.getWidth(rowData.name(), false); + parts.add(ScriptedListHudPanel.textPart(" " + rowData.amp(), withAlpha(uiAmpText & 0x00FFFFFF, blinkAlpha), labelWidth)); + } + LinkedHashMap row = ScriptedListHudPanel.row( + rowData.key(), + "texture", + ScriptedListHudPanel.idString(rowData.iconId()), + parts, + rowData.duration(), + withAlpha(uiCounter & 0x00FFFFFF, blinkAlpha), + dividerColor, + anim + ); + row.put("iconTint", ScriptedListHudPanel.hex(withAlpha(0x00FFFFFF, blinkAlpha))); + panelRows.add(row); + } + } + rowTextRenderer.end(); + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedListHudPanel.Panel( + ScriptedListHudPanel.POTIONS, + new ScriptedListHudPanel.Palette(uiHeaderLeft, uiHeaderRight, uiBodyLeft, uiBodyRight, + uiOutline, uiText, uiMuted, uiCounter, uiTitleText, uiDivider, uiBlurTint), + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderIconH, + drawHeaderTextH, + drawRowTextH, + activeLabelWidth * drawScale, + activeCountWidth * drawScale, + rows.size(), + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + resolvedHeaderIconColor, + HudPanelLayoutModes.current(layoutMode), + panelRows + ) + ); + } + + @Override + public boolean isDraggable() { + return super.isDraggable(); + } + + private void renderExampleRow(Renderer2D renderer, + TextRenderer rowTextRenderer, + float fontScale, + float rowTextH, + float baseScale, + float renderWidth, + float centerY, + float renderX) { + int textColor = withAlpha(uiText & 0x00FFFFFF, 255); + int timeColor = withAlpha(uiCounter & 0x00FFFFFF, 255); + int dividerColor = withAlpha(uiMuted & 0x00FFFFFF, 130); + float iconSize = ROW_ICON_SIZE * baseScale; + float rowContentCenterY = centerY + (ROW_CONTENT_CENTER_OFFSET * baseScale); + Identifier previewIcon = PREVIEW_ICONS[(int) ((System.currentTimeMillis() / 1000L) % PREVIEW_ICONS.length)]; + + Renderer2D.TEXTURE.roundedTexRect( + renderX + (ROW_ICON_X * baseScale), + rowContentCenterY - (iconSize * 0.5f), + iconSize, + iconSize, + Math.min(1.15f * baseScale, iconSize * 0.18f), + 1.0f, + withAlpha(0x00FFFFFF, 255), + previewIcon + ); + + renderer.quad( + renderX + (ROW_DIVIDER_X * baseScale), + rowContentCenterY - ((ROW_DIVIDER_H * baseScale) * 0.5f), + Math.max(0.5f, 0.5f * baseScale), + ROW_DIVIDER_H * baseScale, + dividerColor + ); + + rowTextRenderer.begin(fontScale, false, false); + float textY = rowContentCenterY - (rowTextH * 0.5f); + rowTextRenderer.render("Example effect", + renderX + (ROW_TEXT_X * baseScale), textY, new RenderColor(textColor), false); + float durationWidth = (float) rowTextRenderer.getWidth("**:**", false); + rowTextRenderer.render("**:**", + renderX + renderWidth - durationWidth - (ROW_TIME_RIGHT * baseScale), + textY, + new RenderColor(timeColor), + false + ); + rowTextRenderer.end(); + } + + private List collectRows() { + List rows = new ArrayList<>(); + if (mc != null && mc.player != null) { + mc.player.getActiveEffects().stream() + .sorted((a, b) -> String.CASE_INSENSITIVE_ORDER.compare( + I18n.get(a.getDescriptionId()), + I18n.get(b.getDescriptionId()) + )) + .forEach(inst -> { + String name = I18n.get(inst.getDescriptionId()); + String amp = inst.getAmplifier() > 0 ? Integer.toString(inst.getAmplifier() + 1) : ""; + String duration = formatDuration(inst); + Identifier effectId = inst.getEffect().unwrapKey().map(ResourceKey::identifier).orElse(null); + String key = effectId != null ? effectId.toString() : name; + Identifier iconId = effectId != null + ? Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/" + effectId.getPath() + ".png") + : null; + rows.add(new Row(key, inst, name, amp, duration, iconId)); + }); + } + return rows; + } + + private List animateRows(List rows) { + Map current = new LinkedHashMap<>(); + for (Row row : rows) { + current.put(row.key(), row); + } + + Map merged = new LinkedHashMap<>(); + merged.putAll(current); + for (Map.Entry entry : lastEntries.entrySet()) { + merged.putIfAbsent(entry.getKey(), entry.getValue()); + } + lastEntries.clear(); + lastEntries.putAll(merged); + + List toRemove = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + String key = entry.getKey(); + float target = current.containsKey(key) ? 1.0f : 0.0f; + float anim = entryAnim.getOrDefault(key, 0.0f); + anim = AnimationUtility.approach(anim, target, AnimationUtility.deltaTime(), 12.0f); + anim = AnimationUtility.snap(anim, target, 0.02f); + if (anim <= 0.0f && target <= 0.0f) { + toRemove.add(key); + } else { + entryAnim.put(key, anim); + } + } + for (String key : toRemove) { + entryAnim.remove(key); + lastEntries.remove(key); + } + + List out = new ArrayList<>(); + for (Map.Entry entry : lastEntries.entrySet()) { + float anim = entryAnim.getOrDefault(entry.getKey(), 0.0f); + if (anim > 0.01f) { + out.add(new AnimatedRow(entry.getValue(), anim)); + } + } + return out; + } + + private float totalAnimatedHeight(List rows, float rowStep) { + float out = 0.0f; + for (AnimatedRow row : rows) { + out += rowStep * row.anim(); + } + return out; + } + + private String formatDuration(MobEffectInstance inst) { + int rawTicks = inst.getDuration(); + if (rawTicks < 0) { + return "**:**"; + } + int ticks = Math.max(0, rawTicks); + int minutes = ticks / 1200; + if (minutes > 60) { + return "**:**"; + } + return minutes + ":" + String.format("%02d", (ticks % 1200) / 20); + } + + private int getBlinkAlpha(Row row, int baseAlpha, int minAlphaFloor) { + MobEffectInstance inst = row.effect(); + if (inst == null) { + return baseAlpha; + } + int duration = inst.getDuration(); + if (duration <= 0 || duration > BLINK_THRESHOLD_TICKS) { + return baseAlpha; + } + + double wave = Math.sin(System.currentTimeMillis() * BLINK_SPEED); + float factor = (float) ((wave + 1.0) * 0.5); + int minAlpha = Math.max(0, Math.min(baseAlpha, minAlphaFloor)); + return clamp255(Math.round(minAlpha + (baseAlpha - minAlpha) * (1.0f - factor))); + } + + private boolean isBadEffect(MobEffectInstance effect) { + return effect != null && effect.getEffect().value().getCategory() == MobEffectCategory.HARMFUL; + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiAmpText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.22f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + return; + } + + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiAmpText = uiText; + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + private int withAlpha(int rgb, int alpha) { + return (clamp255(alpha) << 24) | (rgb & 0x00FFFFFF); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private record Row(String key, MobEffectInstance effect, String name, String amp, String duration, + Identifier iconId) { + } + + private record AnimatedRow(Row row, float anim) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Radar.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Radar.java new file mode 100644 index 0000000..2d20804 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Radar.java @@ -0,0 +1,387 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Player; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.relations.CategoryService; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.player.PlayerHealthResolver; + +import java.util.List; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +/** + * Player radar widget with a rectangular map and text list. + */ +@HudElementRegister(order = 140) +public final class Radar extends DraggableHudElement { + + { + defaultLayout(1666.0f, 17.915222f); + } + + + private static final float BASE_RADIUS = 6f; + private static final float BASE_LINE_W = 1.0f; + private static final float BASE_PAD = 3f; + private static final float BLIP_SIZE = 4f; + private static final float BLIP_MARGIN = 6f; + private static final float TEXT_SCALE_MULT = 0.6f; + private static final String FIELD_KEY_PREFIX = "radar_field_"; + private static final Field[] FIELD_OPTIONS = { + Field.NAME, + Field.HP, + Field.DISTANCE, + Field.PING, + Field.NONE + }; + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final EnumValue mode = + new EnumValue<>("radar_mode", Mode.RECT, Mode.RECT, Mode.TEXT); + private final EnumValue colorMode = + new EnumValue<>("radar_color_mode", ColorMode.SYNC, ColorMode.SYNC, ColorMode.CUSTOM); + private final NumberValue size = + new NumberValue<>("radar_size", 217.27, 20.0, 300.0); + private final RGBAColorValue bgColor = + new RGBAColorValue("radar_bg_color", "#CC101010"); + private final NumberValue bgAlpha = + new NumberValue<>("radar_bg_alpha", 109, 0, 255); + private final RGBColorValue textColor = + new RGBColorValue("radar_text_color", "#FFFFFF"); + private final EnumValue field1 = fieldSlot(1, Field.NAME); + private final EnumValue field2 = fieldSlot(2, Field.HP); + private final EnumValue field3 = fieldSlot(3, Field.PING); + private final EnumValue field4 = fieldSlot(4, Field.NONE); + private final EnumValue field5 = fieldSlot(5, Field.NONE); + private final BooleanValue blur = + new BooleanValue("radar_blur", true); + private final NumberValue blurAlpha = + new NumberValue<>("radar_blur_alpha", 255, 0, 255); + private final BooleanValue gradient = + new BooleanValue("radar_gradient", false); + public Radar() { + super("radar", "Radar", false); + } + + private static EnumValue fieldSlot(int slot, Field def) { + return new EnumValue<>(FIELD_KEY_PREFIX + slot, def, FIELD_OPTIONS); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.mode(mode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.number(size)); + defs.add(SettingDef.color(bgColor) + .visibleWhen(() -> colorMode.get() == ColorMode.CUSTOM)); + defs.add(SettingDef.number(bgAlpha) + .visibleWhen(() -> colorMode.get() == ColorMode.SYNC)); + defs.add(SettingDef.colorNoAlpha(textColor) + .visibleWhen(() -> mode.get() == Mode.TEXT + && colorMode.get() == ColorMode.CUSTOM)); + defs.add(SettingDef.mode(field1) + .visibleWhen(() -> mode.get() == Mode.TEXT)); + defs.add(SettingDef.mode(field2) + .visibleWhen(() -> mode.get() == Mode.TEXT)); + defs.add(SettingDef.mode(field3) + .visibleWhen(() -> mode.get() == Mode.TEXT)); + defs.add(SettingDef.mode(field4) + .visibleWhen(() -> mode.get() == Mode.TEXT)); + defs.add(SettingDef.mode(field5) + .visibleWhen(() -> mode.get() == Mode.TEXT)); + defs.add(SettingDef.bool(blur)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(blur::get)); + defs.add(SettingDef.bool(gradient) + .visibleWhen(() -> colorMode.get() == ColorMode.SYNC)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 120f; + this.y = 120f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + return; + } + + if (mode.get() == Mode.TEXT) { + renderTextMode(textRenderer, screenW, screenH, preview); + } else { + renderRectMode(renderer, tickDelta, screenW, screenH); + } + } + + private void renderRectMode(Renderer2D renderer, float tickDelta, int screenW, int screenH) { + float scale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f); + float size = this.size.get().floatValue() * scale; + float baseX = x; + float baseY = y; + float radius = Math.min(BASE_RADIUS * scale, size * 0.25f); + float lineW = Math.max(0.5f, BASE_LINE_W * scale); + float pad = BASE_PAD * scale; + + int bgAlpha = this.bgAlpha.get(); + boolean syncTheme = colorMode.get() == ColorMode.SYNC; + int bgRgb = syncTheme + ? (theme().windowBg() & 0x00FFFFFF) + : (bgColor.getArgb() & 0x00FFFFFF); + int bg = (bgAlpha << 24) | bgRgb; + + boolean blurEnabled = blur.get(); + if (blurEnabled) { + drawBlur(baseX, baseY, size, size, radius, bg); + } + boolean useGradient = syncTheme && gradient.get(); + HudRenderUtil.drawHudBackground(renderer, baseX, baseY, size, size, radius, 1.0f, bg, useGradient); + + int stroke = HudRenderUtil.setAlpha(theme().accent(), 0x55); + renderer.roundedRectStroke(baseX, baseY, size, size, radius, 1.0f, + Math.max(1.0f, 1.25f * scale), stroke); + + int cross = HudRenderUtil.setAlpha(theme().textMuted(), 0x55); + float cx = baseX + size * 0.5f; + float cy = baseY + size * 0.5f; + renderer.quad(cx - lineW * 0.5f, baseY + pad, lineW, size - pad * 2f, cross); + renderer.quad(baseX + pad, cy - lineW * 0.5f, size - pad * 2f, lineW, cross); + + renderBlips(renderer, tickDelta, cx, cy, size * 0.5f - BLIP_MARGIN * scale, scale); + + width = size; + height = size; + } + + private void renderBlips(Renderer2D renderer, float tickDelta, float cx, float cy, float half, float scale) { + if (mc == null || mc.player == null || mc.level == null) return; + + float blip = Math.max(2.5f, BLIP_SIZE * scale); + float blipRadius = Math.max(1.0f, blip * 0.5f); + float yaw = mc.player.getViewYRot(tickDelta); + float yawRad = (float) Math.toRadians(yaw); + float cos = Mth.cos(yawRad); + float sin = Mth.sin(yawRad); + + for (Player player : mc.level.players()) { + if (player == mc.player) continue; + + double px = player.getX(); + double pz = player.getZ(); + double dx = px - mc.player.getX(); + double dz = pz - mc.player.getZ(); + + float rotY = (float) (-(dz * cos - dx * sin)) * 2.0f; + float rotX = (float) (-(dx * cos + dz * sin)) * 2.0f; + + rotX = Mth.clamp(rotX, -half, half); + rotY = Mth.clamp(rotY, -half, half); + + int color = CategoryService.getColor(player); + renderer.roundedRect(cx + rotX - blip * 0.5f, cy + rotY - blip * 0.5f, + blip, blip, blipRadius, 1.0f, color); + } + } + + private void renderTextMode(TextRenderer textRenderer, int screenW, int screenH, boolean preview) { + boolean ready = mc != null && mc.player != null && mc.level != null; + if (!ready && !preview) { + width = 0f; + height = 0f; + return; + } + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer renderer = Fonts.renderer("Iosevka", FontInfo.Type.Regular, fallback); + + float scale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * TEXT_SCALE_MULT; + + renderer.begin(scale, false, false); + float lineH = (float) renderer.getHeight(false); + renderer.end(); + + int textColor = colorMode.get() == ColorMode.CUSTOM + ? 0xFF000000 | (this.textColor.getArgb() & 0x00FFFFFF) + : theme().accent(); + + float maxW = 0f; + float yOff = 0f; + if (ready) { + for (Player player : mc.level.players()) { + if (player == mc.player) continue; + String line = buildLine(player); + if (line.isEmpty()) continue; + + renderer.begin(scale, false, false); + float w = (float) renderer.getWidth(line, false); + renderer.render(line, x, y + yOff, new RenderColor(textColor), false); + renderer.end(); + + maxW = Math.max(maxW, w); + yOff += lineH; + } + } + + if (preview && yOff <= 0f) { + String line = "Player 20 40ms"; + renderer.begin(scale, false, false); + float w = (float) renderer.getWidth(line, false); + renderer.render(line, x, y, new RenderColor(textColor), false); + renderer.end(); + maxW = w; + yOff = lineH; + } + + width = maxW; + height = Math.max(lineH, yOff); + } + + private String buildLine(Player player) { + StringBuilder out = new StringBuilder(64); + appendField(out, field1.get(), player); + appendField(out, field2.get(), player); + appendField(out, field3.get(), player); + appendField(out, field4.get(), player); + appendField(out, field5.get(), player); + return out.toString().trim(); + } + + private void appendField(StringBuilder out, Field field, Player player) { + if (field == null || field == Field.NONE) return; + if (out.length() > 0) out.append(' '); + switch (field) { + case HP -> { + int hp = (int) Math.ceil(PlayerHealthResolver.totalHealth(player)); + out.append(hp); + } + case DISTANCE -> { + int dist = (int) Math.ceil(mc.player.distanceTo(player)); + out.append(dist).append('m'); + } + case PING -> { + int ping = getPlayerPing(player); + if (ping >= 0) { + out.append(ping).append("ms"); + } else { + out.append("--"); + } + } + case NAME -> out.append(player.getName().getString()); + default -> { + // ignore + } + } + } + + private int getPlayerPing(Player player) { + if (mc == null || mc.getConnection() == null) return -1; + PlayerInfo entry = mc.getConnection().getPlayerInfo(player.getUUID()); + return entry != null ? entry.getLatency() : -1; + } + + private void drawBlur(float x, float y, float w, float h, float radius, int tintRgb) { + float quality = hud.getBlurRadius(); + float brightness = 1.0f; + float alpha = blurAlpha.get() / 255f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, brightness, alpha, 0xFFFFFF); + } + + private enum Mode implements EnumValue.IdProvider { + RECT("Rect"), + TEXT("Text"); + + private final String id; + + Mode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum ColorMode implements EnumValue.IdProvider { + SYNC("Sync"), + CUSTOM("Custom"); + + private final String id; + + ColorMode(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } + + private enum Field implements EnumValue.IdProvider { + NAME("Name"), + HP("Hp"), + DISTANCE("Distance"), + PING("Ping"), + NONE("None"); + + private final String id; + + Field(String id) { + this.id = id; + } + + @Override + public String id() { + return id; + } + } +} + + + + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Scoreboard.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Scoreboard.java new file mode 100644 index 0000000..3ab8f7c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Scoreboard.java @@ -0,0 +1,922 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.numbers.NumberFormat; +import net.minecraft.network.chat.numbers.StyledFormat; +import net.minecraft.world.scores.DisplaySlot; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.PlayerScoreEntry; +import net.minecraft.world.scores.PlayerTeam; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.features.module.modules.visuals.NoRender; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.svg.SvgRenderOptions; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextGlyphFallback; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.input.KeyManager; +import dev.loki.lovisual.util.logging.ServerDumpUtil; +import dev.loki.lovisual.util.text.LegacyTextUtil; +import dev.loki.lovisual.util.text.TextRenderUtil; +import dev.loki.lovisual.util.text.TextRenderUtil.Part; + +import java.util.*; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 110) +public final class Scoreboard extends DraggableHudElement { + + { + defaultLayout(1721.69f, 158.0f); + } + + + private static final Comparator ENTRY_COMPARATOR = Comparator.comparingInt(PlayerScoreEntry::value) + .reversed() + .thenComparing(PlayerScoreEntry::owner, String.CASE_INSENSITIVE_ORDER); + + private static final float BASE_PAD_X = 8.0f; + private static final float BASE_PAD_Y = 8.0f; + private static final float BASE_ROW_GAP = 2.0f; + private static final float BASE_HEADER_GAP = 8.0f; + private static final float BASE_SCORE_GAP = 8.0f; + private static final float BASE_FOOTER_GAP = 8.0f; + private static final float BASE_RADIUS = 4.0f; + private static final float BASE_SOFTNESS = 1.0f; + private static final float BASE_STROKE = 0.45f; + private static final float BASE_TITLE_SCALE = 0.78f; + private static final float BASE_TEXT_SCALE = 0.68f; + private static final float BASE_TITLE_LINE_GAP = 1.0f; + private static final int MAX_ENTRIES = 15; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + + private final KeyBindValue toggleBind = + new KeyBindValue("toggle_bind", "Э"); + private final NumberValue scale = + new NumberValue<>("scoreboard_scale", 1.93, 0.5, 5.0); + private final ModeValue colorMode = + new ModeValue("scoreboard_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("scoreboard_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final NumberValue blurAlpha = + new NumberValue<>("scoreboard_blur_alpha", 255, 0, 255); + private final NumberValue bgAlpha = + new NumberValue<>("scoreboard_bg_alpha", 182, 0, 255); + private final RGBAColorValue bg = + new RGBAColorValue("scoreboard_bg", "#820A0A0A"); + private final RGBAColorValue bg2 = + new RGBAColorValue("scoreboard_bg_secondary", "#B8141414"); + private final RGBColorValue stroke = + new RGBColorValue("scoreboard_stroke", "#4A4A4A"); + private final RGBColorValue titleColor = + new RGBColorValue("scoreboard_title_color", "#FFCC00"); + private final RGBColorValue labelColor = + new RGBColorValue("scoreboard_label_color", "#FFFFFF"); + private final RGBColorValue valueColor = + new RGBColorValue("scoreboard_value_color", "#FF4A4A"); + private final RGBColorValue footerColor = + new RGBColorValue("scoreboard_footer_color", "#FFD44A"); + private final EnumValue textEffect = + new EnumValue<>("scoreboard_text_effect", HudTextEffects.Effect.STRIPE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue textEffectSpeed = + new NumberValue<>("scoreboard_text_effect_speed", 18, 1, 60); + private final BooleanMapValue textEffectSections = + new BooleanMapValue("scoreboard_text_effect_sections", defaultTextEffectSections()); + private final List previewLinesSample = List.of( + new SidebarLine(Component.literal("Entry One"), Component.literal("12"), false), + new SidebarLine(Component.literal("Entry Two"), Component.literal("34"), false), + new SidebarLine(Component.literal("Entry Three"), Component.literal("56"), false), + new SidebarLine(Component.literal("Entry Four"), Component.literal("78"), false), + new SidebarLine(Component.literal("Entry Five"), Component.literal("90"), false), + new SidebarLine(Component.literal("Entry Six"), Component.literal("12%"), false), + new SidebarLine(Component.literal("Entry Seven"), Component.literal("3"), false), + new SidebarLine(Component.literal("Footer Text"), Component.empty(), true) + ); + + private final List lines = new ArrayList<>(); + + private boolean toggleArmed = true; + private boolean toggleInit = false; + private int uiBgPrimary; + private int uiBgSecondary; + private int uiHeaderBg; + private int uiStroke; + private int uiTitleColor; + private int uiLabelColor; + private int uiValueColor; + private int uiFooterColor; + private float displayLabelWidth = -1.0f; + private float displayValueWidth = -1.0f; + private float displayFooterWidth = -1.0f; + + public Scoreboard() { + super("scoreboard", "Scoreboard", false); + } + + public static boolean shouldReplaceVanilla() { + return DraggableHudElementRegistry.isEnabled(Scoreboard.class) && !isHiddenByNoRender(); + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + private static boolean isHiddenByNoRender() { + NoRender noRender = Modules.get(NoRender.class); + return noRender != null && noRender.hideScoreboard(); + } + + private static Map defaultTextEffectSections() { + Map sections = new LinkedHashMap<>(); + sections.put("header", true); + sections.put("labels", false); + sections.put("values", false); + sections.put("footer", false); + return sections; + } + + private static float measureStyledText(TextRenderer fallback, Component text, int defaultColor, float scale) { + if (fallback == null || text == null) return 0.0f; + float width = 0.0f; + for (Part part : TextRenderUtil.flattenStyled(text, defaultColor)) { + width += partWidth(fallback, part, scale, 0.0f, 0); + } + return width; + } + + private static List> splitStyledLines(Component text, int defaultColor) { + List> lines = new ArrayList<>(); + List current = new ArrayList<>(); + lines.add(current); + for (Part part : TextRenderUtil.flattenStyled(text, defaultColor)) { + String value = part.text(); + int start = 0; + while (true) { + int newline = value.indexOf('\n', start); + String segment = newline >= 0 ? value.substring(start, newline) : value.substring(start); + if (!segment.isEmpty()) { + current.add(new Part( + segment, + part.color(), + part.bold(), + part.italic(), + part.underline(), + part.strikethrough(), + part.obfuscated() + )); + } + if (newline < 0) { + break; + } + current = new ArrayList<>(); + lines.add(current); + start = newline + 1; + } + } + return lines; + } + + private static float measureStyledLineWidth(TextRenderer fallback, List> lines, float scale) { + if (fallback == null || lines == null || lines.isEmpty()) return 0.0f; + float widest = 0.0f; + for (List line : lines) { + float width = 0.0f; + int partIndex = 0; + for (Part part : line) { + width += partWidth(fallback, part, scale, 0.0f, partIndex++); + } + widest = Math.max(widest, width); + } + return widest; + } + + private static void renderStyledText(Renderer2D shapeRenderer, + TextRenderer fallback, + float scale, + float x, + float y, + Component text, + int defaultColor, + boolean animated, + HudTextEffects.Effect effect, + int effectSpeed, + float timeSec, + float phase) { + if (fallback == null || text == null) return; + float cursorX = x; + int partIndex = 0; + float lineHeight = rendererHeight(fallback, scale); + for (Part part : TextRenderUtil.flattenStyled(text, defaultColor)) { + TextRenderer renderer = styledRenderer(fallback, part); + String renderText = part.obfuscated() ? obfuscate(part.text(), timeSec + phase, partIndex) : part.text(); + float partW = renderTextRuns(shapeRenderer, renderer, scale, cursorX, y, renderText, part.color(), animated, + effect, effectSpeed, timeSec + phase + partIndex * 0.12f); + renderDecorations(shapeRenderer, cursorX, y, partW, lineHeight, part); + cursorX += partW; + partIndex++; + } + } + + private static void renderStyledLinesCentered(Renderer2D shapeRenderer, + TextRenderer fallback, + float scale, + float boxX, + float boxWidth, + float y, + List> lines, + float lineGap, + boolean animated, + HudTextEffects.Effect effect, + int effectSpeed, + float timeSec) { + if (fallback == null || lines == null || lines.isEmpty()) return; + float lineHeight = rendererHeight(fallback, scale); + float cursorY = y; + for (int lineIndex = 0; lineIndex < lines.size(); lineIndex++) { + List line = lines.get(lineIndex); + float lineWidth = 0.0f; + int measurePart = 0; + for (Part part : line) { + lineWidth += partWidth(fallback, part, scale, timeSec + lineIndex * 0.18f, measurePart++); + } + float cursorX = boxX + Math.max(0.0f, (boxWidth - lineWidth) * 0.5f); + int partIndex = 0; + for (Part part : line) { + TextRenderer renderer = styledRenderer(fallback, part); + String renderText = part.obfuscated() ? obfuscate(part.text(), timeSec + lineIndex * 0.18f, partIndex) : part.text(); + float partW = renderTextRuns(shapeRenderer, renderer, scale, cursorX, cursorY, renderText, part.color(), animated, + effect, effectSpeed, timeSec + lineIndex * 0.18f + partIndex * 0.12f); + renderDecorations(shapeRenderer, cursorX, cursorY, partW, lineHeight, part); + cursorX += partW; + partIndex++; + } + cursorY += lineHeight + lineGap; + } + } + + private static TextRenderer styledRenderer(TextRenderer fallback, Part part) { + if (part == null) return fallback; + FontInfo.Type type; + if (part.bold() && part.italic()) { + type = FontInfo.Type.BoldItalic; + } else if (part.bold()) { + type = FontInfo.Type.Bold; + } else if (part.italic()) { + type = FontInfo.Type.Italic; + } else { + type = FontInfo.Type.Regular; + } + TextRenderer renderer = Fonts.renderer("Iosevka", type, fallback); + return renderer != null ? renderer : fallback; + } + + private static TextRenderer rendererForGlyph(TextRenderer preferred, int codePoint) { + return TextGlyphFallback.rendererForGlyph(preferred, codePoint); + } + + private static float rendererHeight(TextRenderer renderer, float scale) { + if (renderer == null) return 0.0f; + renderer.begin(scale, false, false); + float height = (float) renderer.getHeight(false); + renderer.end(); + return height; + } + + private static float partWidth(TextRenderer fallback, Part part, float scale, float timeSec, int salt) { + if (fallback == null || part == null || part.text().isEmpty()) return 0.0f; + TextRenderer renderer = styledRenderer(fallback, part); + String text = part.obfuscated() ? obfuscate(part.text(), timeSec, salt) : part.text(); + return measureTextRuns(renderer, scale, text); + } + + private static float measureTextRuns(TextRenderer preferred, float scale, String text) { + if (preferred == null || text == null || text.isEmpty()) return 0.0f; + TextRenderer current = null; + float width = 0.0f; + float svgAdvance = svgGlyphSize(preferred, scale); + try { + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(preferred, cp)) { + if (current != null) { + current.end(); + current = null; + } + width += svgAdvance; + i += Character.charCount(cp); + continue; + } + String glyph = new String(Character.toChars(cp)); + TextRenderer next = rendererForGlyph(preferred, cp); + if (next != current) { + if (current != null) current.end(); + current = next; + current.begin(scale, false, false); + } + width += (float) current.getWidth(glyph, false); + i += Character.charCount(cp); + } + return width; + } finally { + if (current != null) current.end(); + } + } + + private static float renderTextRuns(Renderer2D shapeRenderer, + TextRenderer preferred, + float scale, + float x, + float y, + String text, + int color, + boolean animated, + HudTextEffects.Effect effect, + int effectSpeed, + float timeSec) { + if (preferred == null || text == null || text.isEmpty()) return 0.0f; + TextRenderer current = null; + StringBuilder run = new StringBuilder(); + float cursorX = x; + float runX = x; + int glyphIndex = 0; + float svgSize = svgGlyphSize(preferred, scale); + float svgY = y + (rendererHeight(preferred, scale) - svgSize) * 0.5f; + for (int i = 0; i < text.length(); ) { + int cp = text.codePointAt(i); + if (TextGlyphFallback.shouldUseVanillaSvg(preferred, cp)) { + if (current != null && !run.isEmpty()) { + cursorX = flushTextRun(current, run.toString(), scale, runX, y, color, animated, + effect, effectSpeed, timeSec + glyphIndex * 0.12f); + run.setLength(0); + } + current = null; + String svgName = TextGlyphFallback.vanillaSvgName(cp); + if (shapeRenderer != null && svgName != null) { + shapeRenderer.svg(svgName, cursorX, svgY, svgSize, svgSize, + SvgRenderOptions.fromFile().withAlpha(((color >>> 24) & 0xFF) / 255.0f)); + } + cursorX += svgSize; + runX = cursorX; + i += Character.charCount(cp); + glyphIndex++; + continue; + } + TextRenderer next = rendererForGlyph(preferred, cp); + if (current != null && next != current) { + cursorX = flushTextRun(current, run.toString(), scale, runX, y, color, animated, + effect, effectSpeed, timeSec + glyphIndex * 0.12f); + run.setLength(0); + runX = cursorX; + } + if (next != current) { + current = next; + } + run.appendCodePoint(cp); + i += Character.charCount(cp); + glyphIndex++; + } + if (current != null && !run.isEmpty()) { + cursorX = flushTextRun(current, run.toString(), scale, runX, y, color, animated, + effect, effectSpeed, timeSec + glyphIndex * 0.12f); + } + return cursorX - x; + } + + private static float svgGlyphSize(TextRenderer renderer, float scale) { + return Math.max(1.0f, rendererHeight(renderer, scale)) * 0.92f; + } + + private static float flushTextRun(TextRenderer renderer, + String text, + float scale, + float x, + float y, + int color, + boolean animated, + HudTextEffects.Effect effect, + int effectSpeed, + float timeSec) { + if (renderer == null || text == null || text.isEmpty()) return x; + renderer.begin(scale, false, false); + boolean rendered = animated && HudTextEffects.render( + renderer, + text, + x, + y, + color, + effect, + effectSpeed, + timeSec, + false + ); + if (!rendered) { + renderer.render(text, x, y, new RenderColor(color), false); + } + float width = (float) renderer.getWidth(text, false); + renderer.end(); + return x + width; + } + + private static void renderDecorations(Renderer2D renderer, + float x, + float y, + float w, + float lineHeight, + Part part) { + if (renderer == null || part == null || w <= 0.0f) return; + if (!part.underline() && !part.strikethrough()) return; + int color = part.color(); + float h = Math.max(0.65f, lineHeight * 0.055f); + if (part.underline()) { + renderer.quad(x, y + lineHeight * 0.88f, w, h, color); + } + if (part.strikethrough()) { + renderer.quad(x, y + lineHeight * 0.54f, w, h, color); + } + } + + private static String obfuscate(String text, float timeSec, int salt) { + if (text == null || text.isEmpty()) return ""; + final String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + int seed = Math.abs((int) (timeSec * 18.0f) * 31 + salt * 131); + StringBuilder out = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + out.append(ch); + } else { + out.append(alphabet.charAt(Math.floorMod(seed + i * 17 + ch, alphabet.length()))); + } + } + return out.toString(); + } + + private static boolean isBlankText(Component text) { + return text == null || text.getString().isBlank(); + } + + private static Component normalizeHeaderTitle(Component text) { + if (text == null) { + return Component.empty(); + } + MutableComponent out = Component.empty(); + text.visit((style, value) -> { + if (value != null && !value.isEmpty()) { + out.append(Component.literal(normalizeHeaderGlyphs(value)).setStyle(style)); + } + return java.util.Optional.empty(); + }, net.minecraft.network.chat.Style.EMPTY); + return out; + } + + private static String normalizeHeaderGlyphs(String input) { + if (input == null || input.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(input.length()); + for (int i = 0; i < input.length(); i++) { + char ch = input.charAt(i); + out.append(switch (ch) { + case 'ᴀ' -> 'a'; + case 'ʙ' -> 'b'; + case 'ᴄ' -> 'c'; + case 'ᴅ' -> 'd'; + case 'ᴇ' -> 'e'; + case 'ꜰ' -> 'f'; + case 'ɢ' -> 'g'; + case 'ʜ' -> 'h'; + case 'ɪ' -> 'i'; + case 'ᴊ' -> 'j'; + case 'ᴋ' -> 'k'; + case 'ʟ' -> 'l'; + case 'ᴍ' -> 'm'; + case 'ɴ' -> 'n'; + case 'ᴏ' -> 'o'; + case 'ᴘ' -> 'p'; + case 'ǫ' -> 'q'; + case 'ʀ' -> 'r'; + case 'ꜱ' -> 's'; + case 'ᴛ' -> 't'; + case 'ᴜ' -> 'u'; + case 'ᴠ' -> 'v'; + case 'ᴡ' -> 'w'; + case 'x' -> 'x'; + case 'ʏ' -> 'y'; + case 'ᴢ' -> 'z'; + default -> ch; + }); + } + return out.toString(); + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.bind(toggleBind, BindMode.PRESS)); + defs.add(SettingDef.number(scale)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(titleColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(labelColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(valueColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(footerColor).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(textEffect)); + defs.add(SettingDef.number(textEffectSpeed).visibleWhen(() -> textEffect.get() != HudTextEffects.Effect.NONE)); + defs.add(SettingDef.group(textEffectSections).visibleWhen(() -> textEffect.get() != HudTextEffects.Effect.NONE)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16.0f; + this.y = screenH * 0.5f - 96.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public HudPhase getHudPhase() { + return HudPhase.BEFORE_CHAT; + } + + @Override + public void onTick() { + if (!toggleInit) { + if (mc == null || mc.getWindow() == null) { + return; + } + initToggleState(); + toggleInit = true; + } + + String combo = toggleBind.get(); + if (combo == null || combo.isBlank() || "NONE".equalsIgnoreCase(combo)) { + return; + } + if (mc == null || mc.getWindow() == null) { + return; + } + + boolean pressed = ClientScreen.current() == null && KeyManager.isComboHeldAllowScreen(combo); + if (!pressed) { + toggleArmed = true; + return; + } + if (toggleArmed) { + toggleNoRenderHidden(); + toggleArmed = false; + } + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.level == null && !preview)) { + width = 0.0f; + height = 0.0f; + return; + } + if (!preview && isHiddenByNoRender()) { + width = 0.0f; + height = 0.0f; + return; + } + if (!preview && !isEnabled()) { + width = 0.0f; + height = 0.0f; + return; + } + + Objective objective = resolveObjective(); + if (objective == null && !preview) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + Component title = resolveTitle(objective, preview); + lines.clear(); + lines.addAll(resolveLines(objective, preview)); + if (lines.isEmpty()) { + width = 0.0f; + height = 0.0f; + return; + } + + float baseScale = HudScale.scale(screenW, screenH) * scale.get().floatValue(); + float padX = BASE_PAD_X * baseScale; + float padY = BASE_PAD_Y * baseScale; + float rowGap = BASE_ROW_GAP * baseScale; + float scoreGap = BASE_SCORE_GAP * baseScale; + float footerGap = BASE_FOOTER_GAP * baseScale; + float radius = BASE_RADIUS * baseScale; + float titleScale = BASE_TITLE_SCALE * baseScale; + float textScale = BASE_TEXT_SCALE * baseScale; + float time = (float) (net.minecraft.util.Util.getMillis() / 1000.0); + HudTextEffects.Effect effect = textEffect.get(); + int effectSpeed = textEffectSpeed.get(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer titleRenderer = Fonts.renderer("Iosevka", FontInfo.Type.BoldItalic, fallback); + TextRenderer bodyRenderer = Fonts.renderer("Iosevka", FontInfo.Type.Regular, titleRenderer); + + Component normalizedTitle = normalizeHeaderTitle(title); + + List> titleLines = splitStyledLines(normalizedTitle, uiTitleColor); + float titleW = measureStyledLineWidth(titleRenderer, titleLines, titleScale); + float titleH = rendererHeight(titleRenderer, titleScale); + + float textH = rendererHeight(bodyRenderer, textScale); + float maxLabelW = 0.0f; + float maxValueW = 0.0f; + float maxFooterW = 0.0f; + for (SidebarLine line : lines) { + if (line.footer()) { + maxFooterW = Math.max(maxFooterW, measureStyledText(bodyRenderer, line.label(), uiFooterColor, textScale)); + continue; + } + maxLabelW = Math.max(maxLabelW, measureStyledText(bodyRenderer, line.label(), uiLabelColor, textScale)); + if (!isBlankText(line.value())) { + maxValueW = Math.max(maxValueW, measureStyledText(bodyRenderer, line.value(), uiValueColor, textScale)); + } + } + + displayLabelWidth = smoothWidth(displayLabelWidth, maxLabelW); + displayValueWidth = smoothWidth(displayValueWidth, maxValueW); + displayFooterWidth = smoothWidth(displayFooterWidth, maxFooterW); + + float widestRow = displayLabelWidth + (displayValueWidth > 0.0f ? scoreGap + displayValueWidth : 0.0f); + float widest = Math.max(titleW, Math.max(widestRow, displayFooterWidth)); + width = padX * 2.0f + widest; + float titleLineGap = BASE_TITLE_LINE_GAP * baseScale; + float titleBlockHeight = titleLines.size() * titleH + Math.max(0, titleLines.size() - 1) * titleLineGap; + float headerHeight = titleBlockHeight + padY * 2.0f; + + int footerCount = 0; + int bodyCount = 0; + for (SidebarLine line : lines) { + if (line.footer()) { + footerCount++; + } else { + bodyCount++; + } + } + + float rowsH = bodyCount > 0 ? bodyCount * textH + Math.max(0, bodyCount - 1) * rowGap : 0.0f; + float footersH = footerCount > 0 ? footerCount * textH + Math.max(0, footerCount - 1) * rowGap : 0.0f; + float footerSectionGap = footerCount > 0 && bodyCount > 0 ? footerGap : 0.0f; + height = headerHeight + padY + rowsH + footerSectionGap + footersH + padY; + + boolean blurEnabled = isBlurEffect(); + if (blurEnabled) { + drawBlur(x, y, width, height, radius, uiBgPrimary); + } + drawBackground(renderer, x, y, width, height, radius, baseScale); + drawHeader(renderer, x, y, width, headerHeight, radius); + + float cursorY = y + headerHeight + padY; + float titleY = y + (headerHeight - titleBlockHeight) * 0.5f; + + renderStyledLinesCentered(renderer, titleRenderer, titleScale, x, width, titleY, titleLines, titleLineGap, + textEffectSections.get("header"), effect, effectSpeed, time); + + boolean footerStarted = false; + for (SidebarLine line : lines) { + if (line.footer()) { + if (!footerStarted && bodyCount > 0) { + cursorY += footerGap; + footerStarted = true; + } + renderStyledText(renderer, bodyRenderer, textScale, x + padX, cursorY, line.label(), uiFooterColor, + textEffectSections.get("footer"), effect, effectSpeed, time, 0.0f); + cursorY += textH + rowGap; + continue; + } + + renderStyledText(renderer, bodyRenderer, textScale, x + padX, cursorY, line.label(), uiLabelColor, + textEffectSections.get("labels"), effect, effectSpeed, time, 0.0f); + if (!isBlankText(line.value())) { + float valueW = measureStyledText(bodyRenderer, line.value(), uiValueColor, textScale); + float valueX = x + width - padX - valueW; + renderStyledText(renderer, bodyRenderer, textScale, valueX, cursorY, line.value(), uiValueColor, + textEffectSections.get("values"), effect, effectSpeed, time, 0.2f); + } + cursorY += textH + rowGap; + } + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + private Objective resolveObjective() { + if (mc == null || mc.level == null || mc.player == null) { + return null; + } + + net.minecraft.world.scores.Scoreboard scoreboard = mc.level.getScoreboard(); + Objective objective = null; + PlayerTeam team = scoreboard.getPlayersTeam(mc.player.getScoreboardName()); + if (team != null) { + DisplaySlot slot = team.getColor().map(net.minecraft.world.scores.TeamColor::displaySlot).orElse(null); + if (slot != null) { + objective = scoreboard.getDisplayObjective(slot); + } + } + return objective != null ? objective : scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR); + } + + private Component resolveTitle(Objective objective, boolean preview) { + if (objective == null) { + return preview ? Component.literal("Scoreboard") : Component.empty(); + } + Component title = LegacyTextUtil.convertLegacyCodes(objective.getDisplayName()); + return isBlankText(title) ? Component.literal("Scoreboard") : title; + } + + private List resolveLines(Objective objective, boolean preview) { + if (objective == null) { + return preview ? previewLinesSample : List.of(); + } + + net.minecraft.world.scores.Scoreboard scoreboard = objective.getScoreboard(); + NumberFormat numberFormat = objective.numberFormatOrDefault(StyledFormat.SIDEBAR_DEFAULT); + List resolved = new ArrayList<>(); + List visibleEntries = scoreboard.listPlayerScores(objective) + .stream() + .filter(entry -> !entry.isHidden()) + .sorted(ENTRY_COMPARATOR) + .limit(MAX_ENTRIES) + .toList(); + + visibleEntries.forEach(entry -> { + PlayerTeam team = scoreboard.getPlayersTeam(entry.owner()); + Component label = LegacyTextUtil.convertLegacyCodes(PlayerTeam.formatNameForTeam(team, entry.ownerName())); + Component value = LegacyTextUtil.convertLegacyCodes(entry.formatValue(numberFormat)); + if (isBlankText(label) && isBlankText(value)) { + return; + } + resolved.add(new SidebarLine(label, value, isBlankText(value))); + }); + + ServerDumpUtil.dumpScoreboardSidebar( + objective, + visibleEntries, + resolved.stream().map(line -> line.label().getString()).toList(), + resolved.stream().map(line -> line.value().getString()).toList() + ); + + return resolved.isEmpty() && preview ? previewLinesSample : resolved; + } + + private void toggleNoRenderHidden() { + NoRender noRender = Modules.get(NoRender.class); + if (noRender == null) return; + noRender.setHideScoreboardRaw(!noRender.hideScoreboardRaw()); + } + + private void initToggleState() { + toggleArmed = true; + String combo = toggleBind.get(); + if (combo != null && !combo.isBlank() && !"NONE".equalsIgnoreCase(combo) + && mc != null && ClientScreen.current() == null + && KeyManager.isComboHeldAllowScreen(combo)) { + toggleArmed = false; + } + } + + private void drawBlur(float x, float y, float w, float h, float radius, int tintRgb) { + if (!hasEffect()) return; + float quality = hud.getBlurRadius(); + float brightness = 1.0f; + float alpha = blurAlpha.get() / 255f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, brightness, alpha, 0xFFFFFF); + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiHeaderBg = HudRenderUtil.setAlpha(theme().windowHeader(), panelAlpha); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiTitleColor = theme().textPrimary(); + uiLabelColor = theme().textMuted(); + uiValueColor = theme().textPrimary(); + uiFooterColor = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.35f); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiHeaderBg = HudRenderUtil.mixColor(bg2.getArgb(), bg.getArgb(), 0.2f); + uiStroke = stroke.getArgb(); + uiTitleColor = titleColor.getArgb(); + uiLabelColor = labelColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiFooterColor = footerColor.getArgb(); + } + + private void drawBackground(Renderer2D renderer, + float x, + float y, + float width, + float height, + float radius, + float drawScale) { + if (isThemeMode()) { + HudRenderUtil.drawHudBackground(renderer, x, y, width, height, radius, BASE_SOFTNESS, uiBgPrimary, true); + } else { + renderer.roundedRectGradientQuad(x, y, width, height, radius, BASE_SOFTNESS, + uiBgPrimary, uiBgSecondary, uiBgPrimary, uiBgSecondary); + } + + renderer.roundedRectStroke( + x, y, width, height, radius, BASE_SOFTNESS, + Math.max(0.5f, BASE_STROKE * drawScale), uiStroke + ); + } + + private void drawHeader(Renderer2D renderer, + float x, + float y, + float width, + float headerHeight, + float radius) { + renderer.roundedRectCorners(x, y, width, headerHeight, radius, radius, 0.0f, 0.0f, BASE_SOFTNESS, uiHeaderBg); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isBlurEffect() { + return EFFECT_BLUR.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private record SidebarLine(Component label, Component value, boolean footer) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedInventoryHudPanel.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedInventoryHudPanel.java new file mode 100644 index 0000000..406741e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedInventoryHudPanel.java @@ -0,0 +1,290 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import java.util.LinkedHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.item.ItemStack; +import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.util.ArrayList; +import java.util.List; + +final class ScriptedInventoryHudPanel { + private static final String LAYOUT_ID = "combatant:api/hud/draggable/inventory_panel"; + private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID); + private final CachedUiScriptRuntime runtime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); + + static List> cells(List stacks) { + List> out = new ArrayList<>(); + if (stacks == null) return out; + for (int i = 0; i < stacks.size(); i++) { + ItemStack stack = stacks.get(i); + if (stack == null || stack.isEmpty()) continue; + LinkedHashMap cell = new LinkedHashMap<>(); + cell.put("slot", i); + cell.put("stack", stack.copy()); + cell.put("item", BuiltInRegistries.ITEM.getKey(stack.getItem()).toString()); + cell.put("count", stack.getCount()); + cell.put("damage", stack.getDamageValue()); + cell.put("maxDamage", stack.getMaxDamage()); + cell.put("componentsHash", stack.getComponents().hashCode()); + out.add(cell); + } + return out; + } + + private static String string(Object value) { + return value instanceof String s ? s : ""; + } + + private static int intValue(Object value) { + return value instanceof Number n ? n.intValue() : 0; + } + + private static int stackHash(Object value) { + if (!(value instanceof ItemStack stack) || stack.isEmpty()) { + return 0; + } + int hash = ItemStack.hashItemAndComponents(stack); + hash = 31 * hash + stack.getCount(); + hash = 31 * hash + stack.getDamageValue(); + hash = 31 * hash + stack.getComponents().hashCode(); + return hash; + } + + private static void putPatch(LinkedHashMap> patches, + String key, + String prop, + Object value) { + LinkedHashMap patch = patches.get(key); + if (patch == null) { + patch = new LinkedHashMap<>(); + patches.put(key, patch); + } + patch.put(prop, value); + } + + boolean render(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + Panel panel) { + if (renderer == null || panel == null || panel.width <= 0.0f || panel.height <= 0.0f) return false; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getResourceManager() == null) return false; + + HudScriptLayouts.pollReloadCombo(mc); + if (moduleHandle.consumeChanged()) { + runtime.reset(); + } + + UiScriptModule module = ensureModule(mc); + if (module == null) return false; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + LinkedHashMap props = panel.toProps(); + UiRuntime baked = runtime.bake( + moduleHandle, + module, + "inventory_panel", + panel.treeSignature(), + panel.dataSignature(), + panel.layoutSignature(), + panel.width, + panel.height, + fallback, + panel.x, + panel.y, + panel.width, + panel.height, + () -> props, + panel::patches + ); + if (baked == null) return false; + baked.render(new UiRenderContext(renderer, fallback, ctx, tickDelta, UiProjectionMode.CURRENT)); + return true; + } + + private UiScriptModule ensureModule(Minecraft mc) { + if (!moduleHandle.ensureLoaded(mc.getResourceManager())) { + HudScriptLayouts.reportLoadError(moduleHandle); + return null; + } + moduleHandle.consumeChanged(); + return moduleHandle.module(); + } + + record Panel(float x, + float y, + float width, + float height, + float drawScale, + float baseScale, + float fontScale, + float headerIconHeight, + float headerTextHeight, + float rowTextHeight, + float countLabelWidth, + float countValueWidth, + long itemCount, + boolean blur, + float blurAlpha, + int headerIconColor, + int gridDivider, + String layout, + ScriptedListHudPanel.Palette palette, + List> items) { + LinkedHashMap toProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("id", "inventory"); + out.put("title", "Inventory"); + out.put("headerIcon", "A"); + out.put("width", width); + out.put("height", height); + out.put("drawScale", drawScale); + out.put("baseScale", baseScale); + out.put("fontScale", fontScale); + out.put("headerIconHeight", headerIconHeight); + out.put("headerTextHeight", headerTextHeight); + out.put("rowTextHeight", rowTextHeight); + out.put("countLabelWidth", countLabelWidth); + out.put("countValueWidth", countValueWidth); + out.put("activeCount", itemCount); + out.put("blur", blur); + out.put("blurAlpha", blurAlpha); + out.put("headerIconColor", ScriptedListHudPanel.hex(headerIconColor)); + out.put("gridDivider", ScriptedListHudPanel.hex(gridDivider)); + out.put("layout", layout != null ? layout : HudPanelLayoutModes.SPLIT_HEADER); + out.put("cols", 9); + out.put("rowsCount", 3); + out.put("gridStartX", 4.0f); + out.put("gridStartY", 22.0f); + out.put("gridStep", 13.0f); + out.put("gridLineOffsetX", 11.0f); + out.put("gridLineOffsetY", 10.0f); + out.put("gridLineLength", 9.0f); + out.put("gridLineThickness", 0.5f); + out.put("itemRenderScale", 0.5f); + out.put("palette", palette.toProps()); + out.put("items", items != null ? items.toArray() : new Object[0]); + out.put("variant", variantProps()); + return out; + } + + private LinkedHashMap variantProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("bodyY", 18.4f); + out.put("headerDividerX", 18.0f); + out.put("titleIconX", 4.5f); + out.put("titleTextX", 22.0f); + out.put("countLabelOffset", 21.0f); + out.put("countValueOffset", 2.0f); + return out; + } + + long treeSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, "inventory"); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + h = CachedUiScriptRuntime.mix(h, drawScale); + h = CachedUiScriptRuntime.mix(h, baseScale); + h = CachedUiScriptRuntime.mix(h, fontScale); + h = CachedUiScriptRuntime.mix(h, headerIconHeight); + h = CachedUiScriptRuntime.mix(h, headerTextHeight); + h = CachedUiScriptRuntime.mix(h, rowTextHeight); + h = CachedUiScriptRuntime.mix(h, countLabelWidth); + h = CachedUiScriptRuntime.mix(h, countValueWidth); + h = CachedUiScriptRuntime.mix(h, blur); + h = CachedUiScriptRuntime.mix(h, blurAlpha); + h = CachedUiScriptRuntime.mix(h, gridDivider); + h = CachedUiScriptRuntime.mix(h, layout); + h = mixPalette(h); + h = CachedUiScriptRuntime.mix(h, String.valueOf(Math.max(0L, itemCount)).length()); + h = CachedUiScriptRuntime.mix(h, (int) Math.min(Integer.MAX_VALUE, Math.max(0L, itemCount))); + h = CachedUiScriptRuntime.mix(h, items != null ? items.size() : 0); + if (items != null) { + for (LinkedHashMap item : items) { + h = CachedUiScriptRuntime.mix(h, intValue(item.get("slot"))); + } + } + return h; + } + + long dataSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, (int) Math.min(Integer.MAX_VALUE, Math.max(0L, itemCount))); + h = CachedUiScriptRuntime.mix(h, headerIconColor); + if (items != null) { + for (LinkedHashMap item : items) { + h = CachedUiScriptRuntime.mix(h, intValue(item.get("slot"))); + h = CachedUiScriptRuntime.mix(h, string(item.get("item"))); + h = CachedUiScriptRuntime.mix(h, intValue(item.get("count"))); + h = CachedUiScriptRuntime.mix(h, intValue(item.get("damage"))); + h = CachedUiScriptRuntime.mix(h, intValue(item.get("maxDamage"))); + h = CachedUiScriptRuntime.mix(h, intValue(item.get("componentsHash"))); + h = CachedUiScriptRuntime.mix(h, stackHash(item.get("stack"))); + } + } + return h; + } + + long layoutSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, x); + h = CachedUiScriptRuntime.mix(h, y); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + return h; + } + + LinkedHashMap> patches() { + LinkedHashMap> patches = new LinkedHashMap<>(); + putPatch(patches, "header:count-value", "text", String.valueOf(Math.max(0L, itemCount))); + putPatch(patches, "header:icon", "color", ScriptedListHudPanel.hex(headerIconColor)); + if (items != null) { + for (LinkedHashMap item : items) { + String key = "item:" + intValue(item.get("slot")); + putPatch(patches, key, "stack", item.get("stack")); + putPatch(patches, key, "item", string(item.get("item"))); + putPatch(patches, key, "count", intValue(item.get("count"))); + putPatch(patches, key, "damage", intValue(item.get("damage"))); + putPatch(patches, key, "maxDamage", intValue(item.get("maxDamage"))); + putPatch(patches, key, "componentsHash", intValue(item.get("componentsHash"))); + } + } + return patches; + } + + private long mixPalette(long h) { + h = CachedUiScriptRuntime.mix(h, palette.headerLeft()); + h = CachedUiScriptRuntime.mix(h, palette.headerRight()); + h = CachedUiScriptRuntime.mix(h, palette.bodyLeft()); + h = CachedUiScriptRuntime.mix(h, palette.bodyRight()); + h = CachedUiScriptRuntime.mix(h, palette.outline()); + h = CachedUiScriptRuntime.mix(h, palette.text()); + h = CachedUiScriptRuntime.mix(h, palette.muted()); + h = CachedUiScriptRuntime.mix(h, palette.counter()); + h = CachedUiScriptRuntime.mix(h, palette.titleText()); + h = CachedUiScriptRuntime.mix(h, palette.divider()); + h = CachedUiScriptRuntime.mix(h, palette.blurTint()); + return h; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedListHudPanel.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedListHudPanel.java new file mode 100644 index 0000000..d049051 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedListHudPanel.java @@ -0,0 +1,501 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import java.util.LinkedHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class ScriptedListHudPanel { + static final Variant POTIONS = new Variant( + "potions", "Potions", String.valueOf((char) 0x42), + 95.0f, 18.0f, 5.0f, 22.0f, 22.0f, 3.0f, + 3.5f, 8.0f, 14.0f, 7.0f, 18.0f, 8.0f, 2.1f + ); + static final Variant COOLDOWNS = new Variant( + "cooldowns", "Cooldowns", "C", + 110.0f, 18.0f, 4.0f, 22.0f, 22.0f, 2.5f, + 3.5f, 8.0f, 15.0f, 6.0f, 18.0f, 8.0f, 1.9f + ); + static final Variant ADMINS = new Variant( + "admins", "Admins", String.valueOf((char) 0x45), + 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 + ); + 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 + ); + static final float HEADER_HEIGHT = 15.5f; + static final float BODY_Y_OFFSET = 18.5f; + static final float CONTENT_START_Y = 25.0f; + static final float BODY_INSET_Y = 6.5f; + static final float ROW_STEP = 11.0f; + static final float PANEL_RADIUS = 4.0f; + static final float PANEL_STROKE = 0.55f; + private static final String LAYOUT_ID = "combatant:api/hud/draggable/list_panel"; + private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID); + private final CachedUiScriptRuntime runtime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); + + static String hex(int argb) { + return String.format("#%08X", argb); + } + + static LinkedHashMap textPart(String text, int color, float x) { + LinkedHashMap part = new LinkedHashMap<>(); + part.put("text", text != null ? text : ""); + part.put("color", hex(color)); + part.put("x", x); + return part; + } + + static LinkedHashMap row(String key, + String iconKind, + String icon, + List> nameParts, + String rightText, + int rightColor, + int dividerColor, + float alpha) { + LinkedHashMap row = new LinkedHashMap<>(); + row.put("key", key != null ? key : ""); + row.put("iconKind", iconKind != null ? iconKind : ""); + row.put("icon", icon != null ? icon : ""); + row.put("nameParts", nameParts != null ? nameParts.toArray() : new Object[0]); + row.put("rightText", rightText != null ? rightText : ""); + row.put("rightColor", hex(rightColor)); + row.put("dividerColor", hex(dividerColor)); + row.put("alpha", alpha); + return row; + } + + static String idString(Identifier id) { + return id != null ? id.toString() : ""; + } + + private static String string(Object value) { + return value instanceof String s ? s : ""; + } + + private static int intValue(Object value) { + return value instanceof Number n ? n.intValue() : 0; + } + + private static float floatValue(Object value) { + return value instanceof Number n ? n.floatValue() : 0.0f; + } + + private static void putPatch(LinkedHashMap> patches, + String key, + String prop, + Object value) { + LinkedHashMap patch = patches.get(key); + if (patch == null) { + patch = new LinkedHashMap<>(); + patches.put(key, patch); + } + patch.put(prop, value); + } + + static List> rows() { + return new ArrayList<>(); + } + + boolean render(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + Panel panel) { + if (renderer == null || panel == null || panel.width <= 0.0f || panel.height <= 0.0f) return false; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getResourceManager() == null) return false; + + HudScriptLayouts.pollReloadCombo(mc); + if (moduleHandle.consumeChanged()) { + runtime.reset(); + } + + UiScriptModule module = ensureModule(mc); + if (module == null) return false; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + LinkedHashMap props = panel.toProps(); + UiRuntime baked = runtime.bake( + moduleHandle, + module, + panel.variant.id, + panel.treeSignature(), + panel.dataSignature(), + panel.layoutSignature(), + panel.width, + panel.height, + fallback, + panel.x, + panel.y, + panel.width, + panel.height, + () -> props, + panel::patches + ); + if (baked == null) return false; + baked.render(new UiRenderContext(renderer, fallback, ctx, tickDelta, UiProjectionMode.CURRENT)); + return true; + } + + private UiScriptModule ensureModule(Minecraft mc) { + if (!moduleHandle.ensureLoaded(mc.getResourceManager())) { + HudScriptLayouts.reportLoadError(moduleHandle); + return null; + } + moduleHandle.consumeChanged(); + return moduleHandle.module(); + } + + record Variant(String id, + String title, + String headerIcon, + float minWidth, + float headerDividerX, + float titleIconX, + float titleTextX, + float countLabelOffset, + float countValueOffset, + float rowIconX, + float rowIconSize, + float rowDividerX, + float rowDividerH, + float rowTextX, + float rowRightPad, + float rowCenterOffset, + String headerIconFont, + float headerIconScale) { + Variant(String id, + String title, + String headerIcon, + float minWidth, + float headerDividerX, + float titleIconX, + float titleTextX, + float countLabelOffset, + float countValueOffset, + float rowIconX, + float rowIconSize, + float rowDividerX, + float rowDividerH, + float rowTextX, + float rowRightPad, + float rowCenterOffset, + String headerIconFont) { + this(id, title, headerIcon, minWidth, headerDividerX, titleIconX, titleTextX, + countLabelOffset, countValueOffset, rowIconX, rowIconSize, rowDividerX, + rowDividerH, rowTextX, rowRightPad, rowCenterOffset, headerIconFont, 1.0f); + } + + Variant(String id, + String title, + String headerIcon, + float minWidth, + float headerDividerX, + float titleIconX, + float titleTextX, + float countLabelOffset, + float countValueOffset, + float rowIconX, + float rowIconSize, + float rowDividerX, + float rowDividerH, + float rowTextX, + float rowRightPad, + float rowCenterOffset) { + this(id, title, headerIcon, minWidth, headerDividerX, titleIconX, titleTextX, + countLabelOffset, countValueOffset, rowIconX, rowIconSize, rowDividerX, + rowDividerH, rowTextX, rowRightPad, rowCenterOffset, "IconsNur", 1.0f); + } + } + + record Palette(int headerLeft, + int headerRight, + int bodyLeft, + int bodyRight, + int outline, + int text, + int muted, + int counter, + int titleText, + int divider, + int blurTint) { + LinkedHashMap toProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("headerLeft", hex(headerLeft)); + out.put("headerRight", hex(headerRight)); + out.put("bodyLeft", hex(bodyLeft)); + out.put("bodyRight", hex(bodyRight)); + out.put("outline", hex(outline)); + out.put("text", hex(text)); + out.put("muted", hex(muted)); + out.put("counter", hex(counter)); + out.put("titleText", hex(titleText)); + out.put("divider", hex(divider)); + out.put("blurTint", hex(blurTint)); + return out; + } + } + + record Panel(Variant variant, Palette palette, float x, float y, float width, float height, float drawScale, + float baseScale, float fontScale, float headerIconHeight, float headerTextHeight, float rowTextHeight, + float countLabelWidth, float countValueWidth, + int activeCount, boolean blur, float blurAlpha, int headerIconColor, String layout, + List> rows) { + Panel(Variant variant, + Palette palette, + float x, + float y, + float width, + float height, + float drawScale, + float baseScale, + float fontScale, + float headerIconHeight, + float headerTextHeight, + float rowTextHeight, + float countLabelWidth, + float countValueWidth, + int activeCount, + boolean blur, + float blurAlpha, + int headerIconColor, + String layout, + List> rows) { + this.variant = variant; + this.palette = palette; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.drawScale = drawScale; + this.baseScale = baseScale; + this.fontScale = fontScale; + this.headerIconHeight = headerIconHeight; + this.headerTextHeight = headerTextHeight; + this.rowTextHeight = rowTextHeight; + this.countLabelWidth = countLabelWidth; + this.countValueWidth = countValueWidth; + this.activeCount = activeCount; + this.blur = blur; + this.blurAlpha = blurAlpha; + this.headerIconColor = headerIconColor; + this.layout = layout != null ? layout : HudPanelLayoutModes.SPLIT_HEADER; + this.rows = rows != null ? rows : List.of(); + } + + LinkedHashMap toProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("id", variant.id); + out.put("title", variant.title); + out.put("headerIcon", variant.headerIcon); + out.put("width", width); + out.put("height", height); + out.put("drawScale", drawScale); + out.put("baseScale", baseScale); + out.put("fontScale", fontScale); + out.put("headerIconHeight", headerIconHeight); + out.put("headerTextHeight", headerTextHeight); + out.put("rowTextHeight", rowTextHeight); + out.put("countLabelWidth", countLabelWidth); + out.put("countValueWidth", countValueWidth); + out.put("activeCount", activeCount); + out.put("blur", blur); + out.put("blurAlpha", blurAlpha); + out.put("headerIconColor", hex(headerIconColor)); + out.put("layout", layout); + out.put("palette", palette.toProps()); + out.put("rows", rows.toArray()); + out.put("variant", variantProps()); + return out; + } + + private Map variantProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("minWidth", variant.minWidth); + out.put("headerDividerX", variant.headerDividerX); + out.put("titleIconX", variant.titleIconX); + out.put("titleTextX", variant.titleTextX); + out.put("countLabelOffset", variant.countLabelOffset); + out.put("countValueOffset", variant.countValueOffset); + out.put("rowIconX", variant.rowIconX); + out.put("rowIconSize", variant.rowIconSize); + out.put("rowDividerX", variant.rowDividerX); + out.put("rowDividerH", variant.rowDividerH); + out.put("rowTextX", variant.rowTextX); + out.put("rowRightPad", variant.rowRightPad); + out.put("rowCenterOffset", variant.rowCenterOffset); + out.put("headerIconFont", variant.headerIconFont); + out.put("headerIconScale", variant.headerIconScale); + return out; + } + + long treeSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, variant.id); + h = mixVariant(h); + h = mixPalette(h); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + h = CachedUiScriptRuntime.mix(h, drawScale); + h = CachedUiScriptRuntime.mix(h, baseScale); + h = CachedUiScriptRuntime.mix(h, fontScale); + h = CachedUiScriptRuntime.mix(h, headerIconHeight); + h = CachedUiScriptRuntime.mix(h, headerTextHeight); + h = CachedUiScriptRuntime.mix(h, rowTextHeight); + h = CachedUiScriptRuntime.mix(h, countLabelWidth); + h = CachedUiScriptRuntime.mix(h, countValueWidth); + h = CachedUiScriptRuntime.mix(h, blur); + h = CachedUiScriptRuntime.mix(h, blurAlpha); + h = CachedUiScriptRuntime.mix(h, layout); + h = CachedUiScriptRuntime.mix(h, String.valueOf(Math.max(0, activeCount)).length()); + h = CachedUiScriptRuntime.mix(h, activeCount); + h = CachedUiScriptRuntime.mix(h, rows.size()); + for (LinkedHashMap row : rows) { + String key = string(row.get("key")); + h = CachedUiScriptRuntime.mix(h, key); + h = CachedUiScriptRuntime.mix(h, string(row.get("iconKind"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("rightText")).length()); + h = CachedUiScriptRuntime.mix(h, Math.round(floatValue(row.get("alpha")) * 100.0f)); + Object partsValue = row.get("nameParts"); + Object[] parts = partsValue instanceof Object[] arr ? arr : new Object[0]; + h = CachedUiScriptRuntime.mix(h, parts.length); + for (Object partValue : parts) { + if (!(partValue instanceof Map part)) continue; + h = CachedUiScriptRuntime.mix(h, string(part.get("text")).length()); + h = CachedUiScriptRuntime.mix(h, floatValue(part.get("x"))); + } + } + return h; + } + + long dataSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, activeCount); + h = CachedUiScriptRuntime.mix(h, headerIconColor); + for (LinkedHashMap row : rows) { + h = CachedUiScriptRuntime.mix(h, string(row.get("key"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("icon"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("iconTint"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("rightText"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("rightColor"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("dividerColor"))); + Object partsValue = row.get("nameParts"); + Object[] parts = partsValue instanceof Object[] arr ? arr : new Object[0]; + for (Object partValue : parts) { + if (!(partValue instanceof Map part)) continue; + h = CachedUiScriptRuntime.mix(h, string(part.get("text"))); + h = CachedUiScriptRuntime.mix(h, string(part.get("color"))); + } + } + return h; + } + + long layoutSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, x); + h = CachedUiScriptRuntime.mix(h, y); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + return h; + } + + LinkedHashMap> patches() { + LinkedHashMap> patches = new LinkedHashMap<>(); + putPatch(patches, "header:count-value", "text", String.valueOf(Math.max(0, activeCount))); + putPatch(patches, "header:icon", "color", hex(headerIconColor)); + for (LinkedHashMap row : rows) { + String key = string(row.get("key")); + String rowPrefix = "row:" + key; + String iconKind = string(row.get("iconKind")); + String icon = string(row.get("icon")); + String iconTint = string(row.get("iconTint")); + if ("item".equals(iconKind)) { + putPatch(patches, rowPrefix + ":item", "item", icon); + } else if ("head".equals(iconKind) && !icon.isEmpty()) { + putPatch(patches, rowPrefix + ":head", "asset", icon); + putPatch(patches, rowPrefix + ":head", "tint", iconTint); + } else if ("texture".equals(iconKind) && !icon.isEmpty()) { + putPatch(patches, rowPrefix + ":texture", "asset", icon); + putPatch(patches, rowPrefix + ":texture", "tint", iconTint); + } else if ("svg".equals(iconKind) && !icon.isEmpty()) { + putPatch(patches, rowPrefix + ":svg", "asset", icon); + putPatch(patches, rowPrefix + ":svg", "tint", iconTint); + } + putPatch(patches, rowPrefix + ":divider", "fill", string(row.get("dividerColor"))); + putPatch(patches, rowPrefix + ":right", "text", string(row.get("rightText"))); + putPatch(patches, rowPrefix + ":right", "color", string(row.get("rightColor"))); + Object partsValue = row.get("nameParts"); + Object[] parts = partsValue instanceof Object[] arr ? arr : new Object[0]; + for (int i = 0; i < parts.length; i++) { + if (!(parts[i] instanceof Map part)) continue; + String partKey = rowPrefix + ":name:" + i; + putPatch(patches, partKey, "text", string(part.get("text"))); + putPatch(patches, partKey, "color", string(part.get("color"))); + } + } + return patches; + } + + private long mixVariant(long h) { + h = CachedUiScriptRuntime.mix(h, variant.title); + h = CachedUiScriptRuntime.mix(h, variant.headerIcon); + h = CachedUiScriptRuntime.mix(h, variant.minWidth); + h = CachedUiScriptRuntime.mix(h, variant.headerDividerX); + h = CachedUiScriptRuntime.mix(h, variant.titleIconX); + h = CachedUiScriptRuntime.mix(h, variant.titleTextX); + h = CachedUiScriptRuntime.mix(h, variant.countLabelOffset); + h = CachedUiScriptRuntime.mix(h, variant.countValueOffset); + h = CachedUiScriptRuntime.mix(h, variant.rowIconX); + h = CachedUiScriptRuntime.mix(h, variant.rowIconSize); + h = CachedUiScriptRuntime.mix(h, variant.rowDividerX); + h = CachedUiScriptRuntime.mix(h, variant.rowDividerH); + h = CachedUiScriptRuntime.mix(h, variant.rowTextX); + h = CachedUiScriptRuntime.mix(h, variant.rowRightPad); + h = CachedUiScriptRuntime.mix(h, variant.rowCenterOffset); + h = CachedUiScriptRuntime.mix(h, variant.headerIconFont); + h = CachedUiScriptRuntime.mix(h, variant.headerIconScale); + return h; + } + + private long mixPalette(long h) { + h = CachedUiScriptRuntime.mix(h, palette.headerLeft); + h = CachedUiScriptRuntime.mix(h, palette.headerRight); + h = CachedUiScriptRuntime.mix(h, palette.bodyLeft); + h = CachedUiScriptRuntime.mix(h, palette.bodyRight); + h = CachedUiScriptRuntime.mix(h, palette.outline); + h = CachedUiScriptRuntime.mix(h, palette.text); + h = CachedUiScriptRuntime.mix(h, palette.muted); + h = CachedUiScriptRuntime.mix(h, palette.counter); + h = CachedUiScriptRuntime.mix(h, palette.titleText); + h = CachedUiScriptRuntime.mix(h, palette.divider); + h = CachedUiScriptRuntime.mix(h, palette.blurTint); + return h; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedTriangulatorHudPanel.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedTriangulatorHudPanel.java new file mode 100644 index 0000000..d4b8f72 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/ScriptedTriangulatorHudPanel.java @@ -0,0 +1,392 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import java.util.LinkedHashMap; +import dev.loki.lovisual.features.gui.hud.script.HudScriptLayouts; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.core.UiRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiProjectionMode; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.render.UiRenderContext; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.CachedUiScriptRuntime; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModule; +import dev.loki.lovisual.render.engine.renderer.ui.runtime.script.UiScriptModuleHandle; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.List; + +final class ScriptedTriangulatorHudPanel { + private static final String LAYOUT_ID = "combatant:api/hud/draggable/triangulator"; + private final UiScriptModuleHandle moduleHandle = HudScriptLayouts.handle(LAYOUT_ID); + private final CachedUiScriptRuntime runtime = new CachedUiScriptRuntime(HudScriptLayouts.runtimeReporter()); + + static String hex(int argb) { + return String.format("#%08X", argb); + } + + static LinkedHashMap row(String key, + String label, + String value, + int labelColor, + int valueColor, + int markerColor, + int dividerColor, + float alpha) { + LinkedHashMap row = new LinkedHashMap<>(); + row.put("key", key != null ? key : ""); + row.put("label", label != null ? label : ""); + row.put("value", value != null ? value : ""); + row.put("labelColor", hex(labelColor)); + row.put("valueColor", hex(valueColor)); + row.put("markerColor", hex(markerColor)); + row.put("dividerColor", hex(dividerColor)); + row.put("alpha", alpha); + return row; + } + + static String idString(Identifier id) { + return id != null ? id.toString() : ""; + } + + static List> rows() { + return new ArrayList<>(); + } + + boolean render(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + Panel panel) { + if (renderer == null || panel == null || panel.width <= 0.0f || panel.height <= 0.0f) return false; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getResourceManager() == null) return false; + + HudScriptLayouts.pollReloadCombo(mc); + if (moduleHandle.consumeChanged()) { + runtime.reset(); + } + + UiScriptModule module = ensureModule(mc); + if (module == null) return false; + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + LinkedHashMap props = panel.toProps(); + UiRuntime baked = runtime.bake( + moduleHandle, + module, + "triangulator", + panel.treeSignature(), + panel.dataSignature(), + panel.layoutSignature(), + panel.width, + panel.height, + fallback, + panel.x, + panel.y, + panel.width, + panel.height, + () -> props, + panel::patches + ); + if (baked == null) return false; + baked.render(new UiRenderContext(renderer, fallback, ctx, tickDelta, UiProjectionMode.CURRENT)); + return true; + } + + private UiScriptModule ensureModule(Minecraft mc) { + if (!moduleHandle.ensureLoaded(mc.getResourceManager())) { + HudScriptLayouts.reportLoadError(moduleHandle); + return null; + } + moduleHandle.consumeChanged(); + return moduleHandle.module(); + } + + private static String string(Object value) { + return value instanceof String s ? s : ""; + } + + private static float floatValue(Object value) { + return value instanceof Number n ? n.floatValue() : 0.0f; + } + + private static void putPatch(LinkedHashMap> patches, + String key, + String prop, + Object value) { + LinkedHashMap patch = patches.get(key); + if (patch == null) { + patch = new LinkedHashMap<>(); + patches.put(key, patch); + } + patch.put(prop, value); + } + record Palette(int headerLeft, + int headerRight, + int bodyLeft, + int bodyRight, + int outline, + int text, + int muted, + int counter, + int titleText, + int divider, + int blurTint, + int success, + int warn, + int danger) { + LinkedHashMap toProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("headerLeft", hex(headerLeft)); + out.put("headerRight", hex(headerRight)); + out.put("bodyLeft", hex(bodyLeft)); + out.put("bodyRight", hex(bodyRight)); + out.put("outline", hex(outline)); + out.put("text", hex(text)); + out.put("muted", hex(muted)); + out.put("counter", hex(counter)); + out.put("titleText", hex(titleText)); + out.put("divider", hex(divider)); + out.put("blurTint", hex(blurTint)); + out.put("success", hex(success)); + out.put("warn", hex(warn)); + out.put("danger", hex(danger)); + return out; + } + } + + record Panel(Palette palette, + float x, + float y, + float width, + float height, + float drawScale, + float baseScale, + float fontScale, + float headerTextHeight, + float rowTextHeight, + float countLabelWidth, + float countValueWidth, + int activeCount, + boolean blur, + float blurAlpha, + String headerIcon, + int headerIconColor, + boolean clearVisible, + int clearIconColor, + boolean copyVisible, + int copyIconColor, + String statusText, + String primaryLine, + String secondaryLine, + String confidenceText, + int statusColor, + List> rows) { + Panel(Palette palette, + float x, + float y, + float width, + float height, + float drawScale, + float baseScale, + float fontScale, + float headerTextHeight, + float rowTextHeight, + float countLabelWidth, + float countValueWidth, + int activeCount, + boolean blur, + float blurAlpha, + String headerIcon, + int headerIconColor, + boolean clearVisible, + int clearIconColor, + boolean copyVisible, + int copyIconColor, + String statusText, + String primaryLine, + String secondaryLine, + String confidenceText, + int statusColor, + List> rows) { + this.palette = palette; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.drawScale = drawScale; + this.baseScale = baseScale; + this.fontScale = fontScale; + this.headerTextHeight = headerTextHeight; + this.rowTextHeight = rowTextHeight; + this.countLabelWidth = countLabelWidth; + this.countValueWidth = countValueWidth; + this.activeCount = activeCount; + this.blur = blur; + this.blurAlpha = blurAlpha; + this.headerIcon = headerIcon != null ? headerIcon : ""; + this.headerIconColor = headerIconColor; + this.clearVisible = clearVisible; + this.clearIconColor = clearIconColor; + this.copyVisible = copyVisible; + this.copyIconColor = copyIconColor; + this.statusText = statusText != null ? statusText : ""; + this.primaryLine = primaryLine != null ? primaryLine : ""; + this.secondaryLine = secondaryLine != null ? secondaryLine : ""; + this.confidenceText = confidenceText != null ? confidenceText : ""; + this.statusColor = statusColor; + this.rows = rows != null ? rows : List.of(); + } + + LinkedHashMap toProps() { + LinkedHashMap out = new LinkedHashMap<>(); + out.put("id", "triangulator"); + out.put("title", "Triangulator"); + out.put("width", width); + out.put("height", height); + out.put("drawScale", drawScale); + out.put("baseScale", baseScale); + out.put("fontScale", fontScale); + out.put("headerTextHeight", headerTextHeight); + out.put("rowTextHeight", rowTextHeight); + out.put("countLabelWidth", countLabelWidth); + out.put("countValueWidth", countValueWidth); + out.put("activeCount", activeCount); + out.put("blur", blur); + out.put("blurAlpha", blurAlpha); + out.put("headerIcon", headerIcon); + out.put("headerIconColor", hex(headerIconColor)); + out.put("clearVisible", clearVisible); + out.put("clearIcon", "x"); + out.put("clearIconColor", hex(clearIconColor)); + out.put("copyVisible", copyVisible); + out.put("copyIcon", "clipboard"); + out.put("copyIconColor", hex(copyIconColor)); + out.put("statusText", statusText); + out.put("primaryLine", primaryLine); + out.put("secondaryLine", secondaryLine); + out.put("confidenceText", confidenceText); + out.put("statusColor", hex(statusColor)); + out.put("palette", palette.toProps()); + out.put("rows", rows.toArray()); + return out; + } + + long treeSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + h = CachedUiScriptRuntime.mix(h, drawScale); + h = CachedUiScriptRuntime.mix(h, baseScale); + h = CachedUiScriptRuntime.mix(h, fontScale); + h = CachedUiScriptRuntime.mix(h, headerTextHeight); + h = CachedUiScriptRuntime.mix(h, rowTextHeight); + h = CachedUiScriptRuntime.mix(h, countLabelWidth); + h = CachedUiScriptRuntime.mix(h, countValueWidth); + h = CachedUiScriptRuntime.mix(h, blur); + h = CachedUiScriptRuntime.mix(h, blurAlpha); + h = CachedUiScriptRuntime.mix(h, clearVisible); + h = CachedUiScriptRuntime.mix(h, copyVisible); + h = CachedUiScriptRuntime.mix(h, String.valueOf(Math.max(0, activeCount)).length()); + h = CachedUiScriptRuntime.mix(h, activeCount); + h = CachedUiScriptRuntime.mix(h, statusText.length()); + h = CachedUiScriptRuntime.mix(h, primaryLine.length()); + h = CachedUiScriptRuntime.mix(h, secondaryLine.length()); + h = CachedUiScriptRuntime.mix(h, confidenceText.length()); + h = CachedUiScriptRuntime.mix(h, rows.size()); + for (LinkedHashMap row : rows) { + h = CachedUiScriptRuntime.mix(h, string(row.get("key"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("label")).length()); + h = CachedUiScriptRuntime.mix(h, string(row.get("value")).length()); + h = CachedUiScriptRuntime.mix(h, Math.round(floatValue(row.get("alpha")) * 100.0f)); + } + return h; + } + + long dataSignature() { + long h = 0xcbf29ce484222325L; + h = mixPalette(h); + h = CachedUiScriptRuntime.mix(h, activeCount); + h = CachedUiScriptRuntime.mix(h, headerIcon); + h = CachedUiScriptRuntime.mix(h, headerIconColor); + h = CachedUiScriptRuntime.mix(h, clearIconColor); + h = CachedUiScriptRuntime.mix(h, copyIconColor); + h = CachedUiScriptRuntime.mix(h, statusText); + h = CachedUiScriptRuntime.mix(h, primaryLine); + h = CachedUiScriptRuntime.mix(h, secondaryLine); + h = CachedUiScriptRuntime.mix(h, confidenceText); + h = CachedUiScriptRuntime.mix(h, statusColor); + for (LinkedHashMap row : rows) { + h = CachedUiScriptRuntime.mix(h, string(row.get("key"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("label"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("value"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("labelColor"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("valueColor"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("markerColor"))); + h = CachedUiScriptRuntime.mix(h, string(row.get("dividerColor"))); + } + return h; + } + + long layoutSignature() { + long h = 0xcbf29ce484222325L; + h = CachedUiScriptRuntime.mix(h, x); + h = CachedUiScriptRuntime.mix(h, y); + h = CachedUiScriptRuntime.mix(h, width); + h = CachedUiScriptRuntime.mix(h, height); + return h; + } + + LinkedHashMap> patches() { + LinkedHashMap> patches = new LinkedHashMap<>(); + putPatch(patches, "header:count-value", "text", String.valueOf(Math.max(0, activeCount))); + putPatch(patches, "header:icon", "tint", hex(headerIconColor)); + putPatch(patches, "clear:icon", "tint", hex(clearIconColor)); + putPatch(patches, "copy:icon", "tint", hex(copyIconColor)); + putPatch(patches, "summary:status", "text", statusText); + putPatch(patches, "summary:status", "color", hex(statusColor)); + putPatch(patches, "summary:primary", "text", primaryLine); + putPatch(patches, "summary:secondary", "text", secondaryLine); + putPatch(patches, "summary:confidence", "text", confidenceText); + putPatch(patches, "summary:confidence", "color", hex(statusColor)); + for (LinkedHashMap row : rows) { + String key = string(row.get("key")); + String prefix = "row:" + key; + putPatch(patches, prefix + ":marker", "fill", string(row.get("markerColor"))); + putPatch(patches, prefix + ":divider", "fill", string(row.get("dividerColor"))); + putPatch(patches, prefix + ":label", "text", string(row.get("label"))); + putPatch(patches, prefix + ":label", "color", string(row.get("labelColor"))); + putPatch(patches, prefix + ":value", "text", string(row.get("value"))); + putPatch(patches, prefix + ":value", "color", string(row.get("valueColor"))); + } + return patches; + } + + private long mixPalette(long h) { + h = CachedUiScriptRuntime.mix(h, palette.headerLeft); + h = CachedUiScriptRuntime.mix(h, palette.headerRight); + h = CachedUiScriptRuntime.mix(h, palette.bodyLeft); + h = CachedUiScriptRuntime.mix(h, palette.bodyRight); + h = CachedUiScriptRuntime.mix(h, palette.outline); + h = CachedUiScriptRuntime.mix(h, palette.text); + h = CachedUiScriptRuntime.mix(h, palette.muted); + h = CachedUiScriptRuntime.mix(h, palette.counter); + h = CachedUiScriptRuntime.mix(h, palette.titleText); + h = CachedUiScriptRuntime.mix(h, palette.divider); + h = CachedUiScriptRuntime.mix(h, palette.blurTint); + h = CachedUiScriptRuntime.mix(h, palette.success); + h = CachedUiScriptRuntime.mix(h, palette.warn); + h = CachedUiScriptRuntime.mix(h, palette.danger); + return h; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SpeedBps.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SpeedBps.java new file mode 100644 index 0000000..0c62e59 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SpeedBps.java @@ -0,0 +1,329 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.player.PlayerSpeedUtil; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +//todo Description +@HudElementInfo( + id = "speed_bps", + displayName = "Speed BPS", + enabledByDefault = true, + order = 90 +) +public final class SpeedBps extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier SPEED_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/speedometer.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 11f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float RIGHT_PAD = 5f; + private static final float KMH_MULT = 3.6f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private static final long SAMPLE_INTERVAL_MS = 75L; + private static final int SAMPLE_COUNT = 20; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final BooleanValue useBps = bool("speed_bps_bps", true); + private final BooleanValue average = bool("speed_bps_average", false); + private final NumberValue scale = num("speed_bps_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("speed_bps_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("speed_bps_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("speed_bps_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue metaColor = visibleWhen(colorNoAlpha("speed_bps_meta_color", "#9B9B9B"), this::isCustomMode); + private final ModeValue bgEffect = mode("speed_bps_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("speed_bps_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("speed_bps_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("speed_bps_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("speed_bps_bg_alpha", 135, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("speed_bps_blur_alpha", 255, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("speed_bps_label_effect", HudTextEffects.Effect.MIX, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("speed_bps_label_effect_speed", 60, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("speed_bps"); + private final float[] samples = new float[SAMPLE_COUNT]; + private final StringBuilder valueBuilder = new StringBuilder(16); + private int sampleIndex = 0; + private int sampleCount = 0; + private long lastSampleMs = 0L; + private float displayedSpeed = 0f; + private int lastTenth = Integer.MIN_VALUE; + private String cachedNumber = "0.0"; + private float displayValueWidth = -1f; + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private int uiMetaColor; + + private static void appendOneDecimal(StringBuilder sb, int tenth) { + if (tenth < 0) { + sb.append('-'); + tenth = -tenth; + } + int intPart = tenth / 10; + int frac = tenth - intPart * 10; + sb.append(intPart); + sb.append('.'); + sb.append((char) ('0' + frac)); + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = screenH - 48f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + float currentBps = preview ? 0f : currentBps(); + updateSamples(currentBps); + + boolean showBps = useBps.get(); + boolean useAverage = average.get(); + float baseBps = useAverage ? averageBps(currentBps) : currentBps; + displayedSpeed = Mth.lerp(0.35f, displayedSpeed, baseBps); + + float valueSpeed = showBps ? displayedSpeed : displayedSpeed * KMH_MULT; + String value = getSpeedNumber(valueSpeed); + String unit = showBps ? "b/s" : "km/h"; + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + TextRenderer metaRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, valueRenderer); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * this.scale.get().floatValue(); + + valueRenderer.begin(drawScale, true, false); + float valueW = (float) valueRenderer.getWidth(value, false); + float valueH = (float) valueRenderer.getHeight(false); + valueRenderer.end(); + + metaRenderer.begin(drawScale, true, false); + float unitW = (float) metaRenderer.getWidth(unit, false); + float unitH = (float) metaRenderer.getHeight(false); + metaRenderer.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconX = x + ICON_X * drawScale; + float iconSize = ICON_SIZE * drawScale; + float unitGap = 2f * drawScale; + displayValueWidth = smoothWidth(displayValueWidth, valueW); + float valueX = x + TEXT_LEFT_X * drawScale; + float boxW = (valueX - x) + displayValueWidth + unitGap + unitW + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - Math.max(valueH, unitH)) * 0.5f; + float unitX = valueX + displayValueWidth + unitGap; + + int iconColor = uiIconColor; + int dividerColor = uiMetaColor; + int valueColor = uiValueColor; + int unitColor = uiMetaColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + iconColor = HudTextEffects.animatedColor(iconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + dividerColor = HudTextEffects.animatedColor(dividerColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.15f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(SPEED_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, iconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(dividerColor, 0x52)); + scriptModel.value().set(value, "Onest", drawScale, valueX - baseX, textY - baseY, displayValueWidth, valueColor); + scriptModel.unit().set(unit, "OnestMedium", drawScale, unitX - baseX, textY - baseY, unitW, unitColor); + scriptModel.extra().hidden(); + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("bps", currentBps) + .data("displayedBps", displayedSpeed) + .data("average", useAverage) + .data("useBps", showBps) + .data("valueText", value) + .data("unitText", unit); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private float currentBps() { + return PlayerSpeedUtil.getBps(mc != null ? mc.player : null); + } + + private void updateSamples(float currentBps) { + long now = System.currentTimeMillis(); + if (now - lastSampleMs < SAMPLE_INTERVAL_MS) return; + lastSampleMs = now; + samples[sampleIndex] = currentBps; + sampleIndex = (sampleIndex + 1) % SAMPLE_COUNT; + if (sampleCount < SAMPLE_COUNT) sampleCount++; + } + + private float averageBps(float fallback) { + if (sampleCount <= 0) return fallback; + float sum = 0f; + for (int i = 0; i < sampleCount; i++) { + sum += samples[i]; + } + return sum / sampleCount; + } + + private String getSpeedNumber(float speed) { + int tenth = Math.round(speed * 10f); + if (tenth == lastTenth) return cachedNumber; + lastTenth = tenth; + valueBuilder.setLength(0); + appendOneDecimal(valueBuilder, tenth); + cachedNumber = valueBuilder.toString(); + return cachedNumber; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + uiMetaColor = theme().textMuted(); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiMetaColor = metaColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} + + diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SystemTime.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SystemTime.java new file mode 100644 index 0000000..f338164 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/SystemTime.java @@ -0,0 +1,278 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import java.time.LocalTime; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "system_time", + displayName = "System Time", + enabledByDefault = false, + order = 170 +) +public final class SystemTime extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float RIGHT_PAD = 5f; + private static final float ICON_SCALE = 0.88f; + private static final String WIDTH_TEMPLATE = "00:00"; + private static final String WIDTH_TEMPLATE_SECONDS = "00:00:00"; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + private static final String[] CLOCK_ICONS = { + iconString(0xEA10), + iconString(0xEA14), + iconString(0xEA15), + iconString(0xEA16), + iconString(0xEA17), + iconString(0xEA18), + iconString(0xEA19), + iconString(0xEA1A), + iconString(0xEA10), + iconString(0xEA11), + iconString(0xEA12), + iconString(0xEA13) + }; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final BooleanValue showSeconds = bool("system_time_seconds", false); + private final NumberValue scale = num("system_time_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("system_time_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("system_time_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("system_time_value_color", "#FFFFFF"), this::isCustomMode); + private final ModeValue bgEffect = mode("system_time_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("system_time_bg", "#EB111318"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("system_time_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("system_time_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("system_time_bg_alpha", 235, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("system_time_blur_alpha", 140, 0, 255), this::hasEffect); + private final EnumValue iconEffect = + enumSetting("system_time_icon_effect", HudTextEffects.Effect.NONE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue iconEffectSpeed = + visibleWhen(num("system_time_icon_effect_speed", 18, 1, 60), + () -> iconEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("system_time"); + + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private float displayTimeWidth = -1f; + + private static String iconString(int codepoint) { + return new String(Character.toChars(codepoint)); + } + + private static TextRenderer getWeatherIcons() { + return Fonts.renderer("WeatherIcons", FontInfo.Type.Regular, TextRenderer.get()); + } + + private static String formatTime(LocalTime time, boolean showSeconds) { + if (showSeconds) { + return String.format("%02d:%02d:%02d", time.getHour(), time.getMinute(), time.getSecond()); + } + return String.format("%02d:%02d", time.getHour(), time.getMinute()); + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 48f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + LocalTime now = LocalTime.now(); + String icon = CLOCK_ICONS[now.getHour() % 12]; + String timeText = formatTime(now, showSeconds.get()); + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer iconRenderer = getWeatherIcons(); + if (iconRenderer == null) iconRenderer = fallback; + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + float iconScale = drawScale * ICON_SCALE; + iconRenderer.begin(iconScale, true, false); + float iconW = (float) iconRenderer.getWidth(icon, false); + float iconH = (float) iconRenderer.getHeight(false); + iconRenderer.end(); + + valueRenderer.begin(drawScale, true, false); + float timeW = (float) valueRenderer.getWidth(timeText, false); + float timeH = (float) valueRenderer.getHeight(false); + String widthTemplate = showSeconds.get() ? WIDTH_TEMPLATE_SECONDS : WIDTH_TEMPLATE; + float reservedTimeW = (float) valueRenderer.getWidth(widthTemplate, false); + valueRenderer.end(); + + displayTimeWidth = Math.max(smoothWidth(displayTimeWidth, timeW), reservedTimeW); + float boxH = BOX_HEIGHT * drawScale; + float boxW = TEXT_LEFT_X * drawScale + displayTimeWidth + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconH) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textX = baseX + TEXT_LEFT_X * drawScale; + float textY = baseY + (boxH - timeH) * 0.5f; + + int resolvedIconColor = uiIconColor; + boolean useIconEffect = iconEffect.get() != HudTextEffects.Effect.NONE; + float nowSec = useIconEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useIconEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, iconEffect.get(), + iconEffectSpeed.get(), nowSec, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().glyph(icon, "WeatherIcons", iconX - baseX, iconY - baseY, iconW, iconH, iconScale, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiValueColor, 0x52)); + scriptModel.value().set(timeText, "Onest", drawScale, textX - baseX, textY - baseY, displayTimeWidth, uiValueColor); + scriptModel.unit().hidden(); + scriptModel.extra().hidden(); + scriptModel.animation().setLabelEffect(iconEffect.get().name(), iconEffectSpeed.get(), nowSec) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("timeText", timeText) + .data("clockIcon", icon) + .data("showSeconds", showSeconds.get()) + .data("hour", now.getHour()) + .data("minute", now.getMinute()) + .data("second", now.getSecond()); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TargetHud.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TargetHud.java new file mode 100644 index 0000000..c5aefea --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TargetHud.java @@ -0,0 +1,1687 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + + +import dev.loki.lovisual.features.theme.Theme; +import dev.loki.lovisual.config.values.*; +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.resources.Identifier; +import net.minecraft.util.Util; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.common.CommonSettingSchemas; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.theme.Themes; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.features.module.modules.combat.AutoAnchor; +import dev.loki.lovisual.features.module.modules.combat.AutoBed; +import dev.loki.lovisual.features.module.modules.combat.AutoCrystal; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.profiler.ProfilerPhase; +import dev.loki.lovisual.render.engine.profiler.RenderProfiler2D; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.player.PlayerHealthResolver; +import dev.loki.lovisual.util.player.PlayerSkinResolver; +import dev.loki.lovisual.util.player.effect.StatusEffectTracker; +import dev.loki.lovisual.util.player.effect.StatusEffectView; +import dev.loki.lovisual.util.pvp.opponents.TotemPopCounter; +import dev.loki.lovisual.util.pvp.opponents.TotemPopSnapshot; +import dev.loki.lovisual.util.target.TargetManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 60) +public final class TargetHud extends DraggableHudElement { + + + { + defaultLayout(7.6799927f, 721.12f, "CENTER", "FREE"); + } + + + private static final float BASE_WIDTH = 112.0f; + private static final float BASE_HEIGHT = 40.0f; + private static final float FACE_SIZE = 24.0f; + private static final float FACE_X = 9.0f; + private static final float FACE_Y = 8.0f; + private static final float FACE_RADIUS = 4.0f; + private static final float CONTENT_GAP = 6.0f; + private static final float CONTENT_X_OFFSET = -2.0f; + private static final float CONTENT_Y_OFFSET = -1.0f; + private static final float PANEL_INSET = 2.0f; + private static final float PANEL_RADIUS = 6.0f; + private static final float NAME_Y = 13.0f; + private static final float BAR_Y_OFFSET = 12.0f; + private static final float BAR_WIDTH = 64.0f; + private static final float BAR_HEIGHT = 4.0f; + private static final float BAR_RADIUS = 2.0f; + private static final float NAME_TEXT_SCALE = 0.34f; + private static final float DISTANCE_TEXT_SCALE = 0.32f; + private static final float HP_TEXT_SCALE = 0.34f; + private static final float ARC_HP_TEXT_SCALE = 0.28f; + private static final float DISTANCE_Y_OFFSET = 8.0f; + private static final float ARC_CENTER_Y = 18.0f; + private static final float ARC_RIGHT_INSET = 13.8f; + private static final float ARC_RADIUS = 9.9f; + private static final float ARC_THICKNESS = 3.5f; + private static final float ARC_RESERVED_WIDTH = 34.0f; + private static final float PLACEHOLDER_TEXT_SCALE = 0.62f; + private static final float BASE_NAME_SCROLL_SPEED = 22.0f; + private static final float BASE_NAME_SCROLL_GAP = 14.0f; + private static final float BASE_NAME_FADE = 12.0f; + private static final float NAME_SCROLL_PAUSE_SEC = 0.8f; + private static final float EFFECTS_PANEL_GAP = 3.0f; + private static final float EFFECTS_CHIP_HEIGHT = 10.0f; + private static final float EFFECTS_CHIP_RADIUS = 3.0f; + private static final float EFFECTS_CHIP_PAD_X = 3.0f; + private static final float EFFECTS_CHIP_GAP = 2.0f; + private static final float EFFECTS_ICON_SIZE = 8.0f; + private static final float EFFECTS_TEXT_GAP = 2.0f; + private static final float EFFECTS_TEXT_SCALE = 0.22f; + private static final float EQUIPMENT_TOP_GAP = 2.0f; + private static final float EQUIPMENT_BOTTOM_PAD = 2.0f; + private static final float EQUIPMENT_SLOT = 11.0f; + private static final float EQUIPMENT_GAP = 2.0f; + private static final float EQUIPMENT_TEXT_SCALE = 0.22f; + private static final float EQUIPMENT_ROW_Y_OFFSET = 1.15f; + private static final long HIT_FLASH_MS = 220L; + private static final float HEALTH_SPEED = 6.0f; + private static final float BAR_SPEED = 4.0f; + private static final float BAR_TAIL_FADE_LENGTH = BAR_HEIGHT; + private static final float ARC_TAIL_FADE_DEGREES = 20.0f; + private static final float PROGRESS_SNAP_EPSILON = 0.0005f; + private static final float DISTANCE_SPEED = 7.0f; + private static final float EQUIPMENT_SPEED = 7.0f; + private static final float HEALTH_TEXT_STEP = 0.25f; + private static final float HEALTH_TEXT_SMALL_STEP = 0.10f; + private static final float HEALTH_ZERO_CLAMP = 0.05f; + private static final float HEALTH_ZERO_SNAP_THRESHOLD = 0.60f; + private static final float TOTEM_BADGE_BOTTOM_PAD = 1.45f; + private static final float TOTEM_BADGE_ICON_SIZE = 8.0f; + private static final float TOTEM_BADGE_TEXT_GAP = 1.25f; + private static final float TOTEM_BADGE_TEXT_SCALE = 0.24f; + private static final float TOTEM_BADGE_ANIM_SPEED = 10.0f; + private static final float TOTEM_BADGE_PULSE_SPEED = 8.0f; + private static ItemStack totemBadgeStack; + + private static ItemStack totemBadgeStack() { + if (totemBadgeStack == null || totemBadgeStack.isEmpty()) { + totemBadgeStack = new ItemStack(Items.TOTEM_OF_UNDYING); + } + return totemBadgeStack; + } + + private static final float FACE_U0 = 8f / 64f; + private static final float FACE_V0 = 8f / 64f; + private static final float FACE_U1 = 16f / 64f; + private static final float FACE_V1 = 16f / 64f; + private static final float HAT_U0 = 40f / 64f; + private static final float HAT_V0 = 8f / 64f; + private static final float HAT_U1 = 48f / 64f; + private static final float HAT_V1 = 16f / 64f; + + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + private static final String EFFECTS_OFF = "Off"; + private static final String EFFECTS_COMPACT = "Compact"; + private static final String EQUIPMENT_OFF = "Off"; + private static final String EQUIPMENT_COMPACT = "Compact"; + private static final String HP_MODE_LINE = "Line"; + private static final String HP_MODE_ARC = "Arc"; + + private static final ConcurrentHashMap HIT_FLASH_MS_BY_ID = new ConcurrentHashMap<>(); + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + + private final NumberValue scale = + new NumberValue<>("target_scale", 3.28, 0.5, 4.0); + private final NumberValue holdMs = + new NumberValue<>("target_hold_ms", 1000, 0, 5000); + private final BooleanValue totemPopCounter = TotemPopCounter.enabledValue(); + private final NumberValue totemPopResetSeconds = TotemPopCounter.resetAfterSecondsValue(); + private final RGBColorValue totemPopTextColor = + new RGBColorValue("target_totem_pop_text_color", "RNB#FFFFFF"); + private final BooleanValue marquee = + new BooleanValue("target_marquee", true); + private final BooleanValue playersOnly = + new BooleanValue("target_players_only", false); + private final ModeValue effectsMode = + new ModeValue("target_effects_mode", "Off", EFFECTS_OFF, EFFECTS_COMPACT); + private final BooleanValue effectsSelf = + new BooleanValue("target_effects_self", true); + private final ModeValue equipmentMode = + new ModeValue("target_equipment_mode", "Off", EQUIPMENT_OFF, EQUIPMENT_COMPACT); + private final ModeValue hpMode = + new ModeValue("target_hp_mode", "Arc", HP_MODE_LINE, HP_MODE_ARC); + private final ModeValue colorMode = + new ModeValue("target_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final RGBAColorValue bg = + new RGBAColorValue("target_bg", "#F7343434"); + private final RGBAColorValue bg2 = + new RGBAColorValue("target_bg_secondary", "#F7161616"); + private final RGBColorValue stroke = + new RGBColorValue("target_stroke", "#5A5A5A"); + private final NumberValue blurAlpha = + new NumberValue<>("target_blur_alpha", 255, 0, 255); + private final NumberValue bgAlpha = + new NumberValue<>("target_bg_alpha", 162, 0, 255); + private final RGBColorValue text = + new RGBColorValue("target_text", "#FFFFFF"); + private final RGBColorValue textSecondary = + new RGBColorValue("target_text_secondary", "#D7D7D7"); + private final RGBColorValue barBg = + new RGBColorValue("target_bar_bg", "#1E1E1E"); + private final RGBColorValue healthStart = + new RGBColorValue("target_health_start", "#F3F3F3"); + private final RGBColorValue healthEnd = + new RGBColorValue("target_health_end", "#9E9E9E"); + private final RGBColorValue trail = + new RGBColorValue("target_trail", "#373737"); + private final RGBColorValue absorptionStart = + new RGBColorValue("target_absorption_start", "#FFC300"); + private final RGBColorValue absorptionEnd = + new RGBColorValue("target_absorption_end", "#FFD96B"); + private final RGBColorValue hitOuter = + new RGBColorValue("target_hit_outer", "#FF5A5A"); + private final RGBColorValue hitInner = + new RGBColorValue("target_hit_inner", "#FF7373"); + private final ModeValue bgEffect = + new ModeValue("target_bg_effect", "Glass", EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS); + private final BooleanValue hitAnim = + new BooleanValue("target_hit_anim", true); + private final NumberValue hitPulse = + new NumberValue<>("target_hit_pulse", 1.0, 0.0, 2.0); + + private LivingEntity displayTarget; + private float visibilityAnimation; + private float healthAnimation; + private float trailAnimation; + private float absorptionAnimation; + private float effectsAnimation; + private float equipmentAnimation; + private float totemPopAnimation; + private float totemPopTextPulse; + private int totemPopLastCount; + private UUID totemPopTargetId; + private float displayedHealth; + private float displayedDistance; + private long lastSeenMs; + private long waveStartMs = System.currentTimeMillis(); + + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiTextPrimary; + private int uiTextSecondary; + private int uiBarBackground; + private int uiHealthStart; + private int uiHealthEnd; + private float uiHealthGradientAngle; + private int uiTrail; + private int uiAbsorptionStart; + private int uiAbsorptionEnd; + private float uiAbsorptionGradientAngle; + private int uiHitOuter; + private int uiHitInner; + + public TargetHud() { + super("target_hud", "TargetHUD", true); + } + + public static void notifyHit(Entity target) { + if (target == null) return; + HIT_FLASH_MS_BY_ID.put(target.getId(), System.currentTimeMillis()); + } + + private static float snapToStep(float value, float step) { + if (step <= 0.0f) return value; + return Math.round(value / step) * step; + } + + private static float formatDisplayHealth(float health) { + float clamped = Math.max(0.0f, health); + if (clamped <= HEALTH_ZERO_CLAMP) { + return 0.0f; + } + float step = clamped < 1.0f ? HEALTH_TEXT_SMALL_STEP : HEALTH_TEXT_STEP; + float snapped = snapToStep(clamped, step); + return snapped <= HEALTH_ZERO_CLAMP ? 0.0f : snapped; + } + + private static String formatHealth(float health) { + if (health >= 100.0f) { + return Integer.toString(Math.round(health)); + } + if (health >= 10.0f) { + return String.format(Locale.ROOT, "%.1f", health); + } + return String.format(Locale.ROOT, "%.2f", health); + } + + private static String formatDistance(float distance) { + if (distance >= 100.0f) { + return Integer.toString(Math.round(distance)); + } + return String.format(Locale.ROOT, "%.1f", distance); + } + + private static int[] buildWaveColors(long elapsedMs, + float waveSpeedMs, + float alphaFactor, + int startColor, + int endColor) { + float phase = (elapsedMs % (long) waveSpeedMs) / waveSpeedMs * (float) Math.PI * 2.0f; + int[] colors = new int[4]; + for (int i = 0; i < 2; i++) { + float wave = ((float) Math.sin(phase - i * 1.5f) + 1.0f) * 0.5f; + int mixed = HudRenderUtil.mixColor(startColor, endColor, wave); + colors[i * 2] = HudRenderUtil.scaleAlpha(mixed, alphaFactor); + colors[i * 2 + 1] = colors[i * 2]; + } + return colors; + } + + private static void drawAnimatedArcGradient(Renderer2D renderer, + float centerX, + float centerY, + float radius, + float thickness, + float startAngle, + float sweep, + float softness, + float alphaFactor, + int startColor, + int endColor, + float baseAngle, + float speedMs, + long elapsedMs, + float phaseOffset) { + float safeSpeed = Math.max(1.0f, speedMs); + float phase = (elapsedMs / safeSpeed) * (float) Math.PI * 2.0f + phaseOffset; + float animatedAngle = baseAngle + + (elapsedMs / safeSpeed) * 92.0f + + (float) Math.sin(phase * 0.63f) * 26.0f; + float pulseA = ((float) Math.sin(phase) + 1.0f) * 0.5f; + float pulseB = ((float) Math.sin(phase + 1.95f) + 1.0f) * 0.5f; + + int c0 = HudRenderUtil.mixColor(startColor, endColor, pulseA * 0.24f); + int c1 = HudRenderUtil.mixColor(endColor, startColor, pulseB * 0.18f); + int drawStart = HudRenderUtil.scaleAlpha(c0, alphaFactor); + int drawEnd = HudRenderUtil.scaleAlpha(c1, alphaFactor); + float offsetPx = (float) Math.sin(phase * 0.71f) * radius * 0.58f; + + renderer.arcStrokeGradient( + centerX, + centerY, + radius, + thickness, + startAngle, + startAngle + sweep, + softness, + drawStart, + drawEnd, + animatedAngle, + offsetPx + ); + + } + + private static float tailFade(float visibleLength, float fadeLength) { + if (fadeLength <= 0.0f) return visibleLength > 0.0f ? 1.0f : 0.0f; + return AnimationUtility.smoothstep(AnimationUtility.clamp01(visibleLength / fadeLength)); + } + + private static float measureText(TextRenderer renderer, String text, float scale) { + renderer.begin(scale, false, false); + try { + return (float) renderer.getWidth(text, false); + } finally { + renderer.end(); + } + } + + private static String trimText(TextRenderer renderer, String text, float maxWidth, float scale) { + if (text == null || text.isEmpty()) return ""; + renderer.begin(scale, false, false); + try { + if (renderer.getWidth(text, false) <= maxWidth) return text; + String value = text; + while (!value.isEmpty()) { + String candidate = value + "..."; + if (renderer.getWidth(candidate, false) <= maxWidth) { + return candidate; + } + value = value.substring(0, value.length() - 1); + } + return ""; + } finally { + renderer.end(); + } + } + + private static int rgba(int red, int green, int blue, int alpha) { + int r = Math.max(0, Math.min(255, red)); + int g = Math.max(0, Math.min(255, green)); + int b = Math.max(0, Math.min(255, blue)); + int a = Math.max(0, Math.min(255, alpha)); + return (a << 24) | (r << 16) | (g << 8) | b; + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scale)); + defs.add(SettingDef.number(holdMs)); + defs.add(SettingDef.bool(totemPopCounter) + .common(CommonSettingSchemas.TOTEM_POP_COUNTER.commonI18nKey())); + defs.add(SettingDef.number(totemPopResetSeconds) + .common(CommonSettingSchemas.TOTEM_POP_RESET_SECONDS.commonI18nKey()) + .visibleWhen(totemPopCounter::get)); + defs.add(SettingDef.colorNoAlpha(totemPopTextColor) + .visibleWhen(totemPopCounter::get)); + defs.add(SettingDef.bool(marquee)); + defs.add(SettingDef.bool(playersOnly)); + defs.add(SettingDef.mode(effectsMode)); + defs.add(SettingDef.bool(effectsSelf).visibleWhen(this::isEffectsEnabled)); + defs.add(SettingDef.mode(equipmentMode)); + defs.add(SettingDef.mode(hpMode)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(() -> isCustomMode() && !isGlassEffect())); + defs.add(SettingDef.color(bg2).visibleWhen(() -> isCustomMode() && !isGlassEffect())); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(() -> isCustomMode() && !isGlassEffect())); + defs.add(SettingDef.number(bgAlpha).visibleWhen(() -> isThemeMode() && !isGlassEffect())); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(textSecondary).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(barBg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(healthStart).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(healthEnd).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(trail).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(absorptionStart).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(absorptionEnd).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(hitOuter).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(hitInner).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.bool(hitAnim)); + defs.add(SettingDef.number(hitPulse).visibleWhen(hitAnim::get)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + float hudScale = HudScale.scale(screenW, screenH) * scale.get().floatValue(); + this.x = screenW / 2f - (BASE_WIDTH * hudScale) * 0.5f; + this.y = screenH / 2f + BASE_HEIGHT * hudScale; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + try (ProfilerPhase.Scope ignored = ProfilerPhase.scope("2d:target_hud:main"); + RenderProfiler2D.Section ignoredSection = RenderProfiler2D.section("target_hud:main")) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null || (mc.player == null && !preview)) return; + + long now = System.currentTimeMillis(); + LivingEntity target = resolveTarget(preview); + updateDisplayTarget(target, now); + + boolean shouldShow = shouldShow(target, now); + float dt = AnimationUtility.deltaTime(); + visibilityAnimation = HudRenderUtil.animateVisibility(visibilityAnimation, shouldShow); + + if (!shouldShow && visibilityAnimation <= 0.001f) { + displayTarget = null; + width = 0f; + height = 0f; + return; + } + if (displayTarget == null) return; + + updatePalette(); + + float hudScale = HudScale.scale(screenW, screenH) * scale.get().floatValue(); + float alphaFactor = AnimationUtility.easeOutCubic(visibilityAnimation); + float hitPulseFactor = computeHitPulse(now); + EffectLayout boundsEffects = buildEffectLayout(displayTarget, preview, textRenderer, hudScale); + boolean hasEffects = !boundsEffects.chips().isEmpty(); + float effectsTarget = hasEffects ? 1.0f : 0.0f; + effectsAnimation = AnimationUtility.approach(effectsAnimation, effectsTarget, dt, EQUIPMENT_SPEED); + effectsAnimation = AnimationUtility.snap(effectsAnimation, effectsTarget, 0.001f); + EquipmentLayout boundsEquipment = buildEquipmentLayout(displayTarget, preview, textRenderer, hudScale); + boolean hasEquipment = !boundsEquipment.entries().isEmpty(); + float equipmentTarget = hasEquipment ? 1.0f : 0.0f; + equipmentAnimation = AnimationUtility.approach(equipmentAnimation, equipmentTarget, dt, EQUIPMENT_SPEED); + equipmentAnimation = AnimationUtility.snap(equipmentAnimation, equipmentTarget, 0.001f); + float scalePulse = hitAnim.get() + ? 1.0f + hitPulseFactor * 0.018f * hitPulse.get().floatValue() + : 1.0f; + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnimation); + + float panelWidthLogical = BASE_WIDTH; + float panelHeightLogical = BASE_HEIGHT; + + float targetWidth = panelWidthLogical * hudScale; + float targetHeight = panelHeightLogical * hudScale; + width = HudRenderUtil.animateDimension(width, targetWidth); + height = HudRenderUtil.animateDimension(height, targetHeight); + + float widthFactor = targetWidth <= 0.0f ? 1.0f : width / targetWidth; + float heightFactor = targetHeight <= 0.0f ? 1.0f : height / targetHeight; + float dimensionFactor = Math.max(0.0f, Math.min(widthFactor, heightFactor)); + float scaleFactor = hudScale * dimensionFactor * widgetScale * scalePulse; + + float drawW = panelWidthLogical * scaleFactor; + float drawH = panelHeightLogical * scaleFactor; + float panelDrawW = panelWidthLogical * scaleFactor; + float panelDrawH = panelHeightLogical * scaleFactor; + float drawX = x + (width - drawW) * 0.5f; + float drawY = y + (height - drawH) * 0.5f; + + float panelBaseX = drawX + (drawW - panelDrawW) * 0.5f; + float panelBaseY = drawY; + float panelX = panelBaseX + PANEL_INSET * scaleFactor; + float panelY = panelBaseY + PANEL_INSET * scaleFactor; + float panelW = panelDrawW - PANEL_INSET * 2f * scaleFactor; + float panelH = panelDrawH - PANEL_INSET * 2f * scaleFactor; + float panelRadius = PANEL_RADIUS * scaleFactor; + + boolean glassEnabled = isGlassEffect(); + boolean blurEnabled = isBlurEffect(); + TotemPopSnapshot totemPopSnapshot = resolveTotemPopSnapshot(displayTarget); + updateTotemPopAnimation(displayTarget, totemPopSnapshot, dt); + + if (glassEnabled) { + drawGlass(panelX, panelY, panelW, panelH, panelRadius, alphaFactor); + } else if (blurEnabled) { + drawBlur(panelX, panelY, panelW, panelH, panelRadius, uiBgPrimary, alphaFactor); + } + + drawBackground(renderer, panelX, panelY, panelW, panelH, panelRadius, alphaFactor, glassEnabled); + drawHitPulse(renderer, panelX, panelY, panelW, panelH, panelRadius, alphaFactor, hitPulseFactor); + drawFace(renderer, textRenderer, ctx, displayTarget, panelX, panelY, scaleFactor, alphaFactor, totemPopSnapshot); + drawContent(renderer, textRenderer, displayTarget, panelX, panelY, panelDrawW, scaleFactor, alphaFactor, now, dt); + if (effectsAnimation > 0.01f) { + EffectLayout drawEffects = buildEffectLayout(displayTarget, preview, textRenderer, scaleFactor); + float effectsY = panelY - drawEffects.height(); + drawEffects(renderer, textRenderer, drawEffects, panelX, effectsY, panelW, scaleFactor, alphaFactor * effectsAnimation, glassEnabled); + } + if (equipmentAnimation > 0.01f) { + EquipmentLayout drawEquipment = buildEquipmentLayout(displayTarget, preview, textRenderer, scaleFactor); + drawEquipment(renderer, textRenderer, ctx, drawEquipment, panelX, panelY + panelH, panelW, drawEquipment.height(), scaleFactor, alphaFactor * equipmentAnimation, glassEnabled); + } + } + } + + private void updateDisplayTarget(LivingEntity target, long now) { + if (target != null) { + if (displayTarget != target) { + displayTarget = target; + PlayerHealthResolver.HealthSnapshot health = PlayerHealthResolver.resolve(target); + float baseMaxHealth = Math.max(1.0f, target.getMaxHealth()); + float realHealth = Math.max(0.0f, health.totalHealth() - health.absorption()); + displayedHealth = health.totalHealth(); + healthAnimation = realHealth / baseMaxHealth; + trailAnimation = healthAnimation; + absorptionAnimation = health.absorption() / baseMaxHealth; + displayedDistance = mc.player != null ? mc.player.distanceTo(target) : 0.0f; + waveStartMs = now; + } + lastSeenMs = now; + return; + } + + if (displayTarget == mc.player && !(ClientScreen.current() instanceof ChatScreen)) { + lastSeenMs = 0L; + } + } + + private boolean shouldShow(LivingEntity target, long now) { + if (target != null) return true; + return displayTarget != null && lastSeenMs > 0L && now - lastSeenMs <= Math.max(0L, holdMs.get()); + } + + private LivingEntity resolveTarget(boolean preview) { + LivingEntity target = TargetManager.getTarget(); + if (target != null && playersOnly.get() && !(target instanceof Player)) { + target = null; + } + if (target == null && (preview || ClientScreen.current() instanceof ChatScreen)) { + target = mc.player; + } + return target; + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiTextPrimary = theme().textPrimary(); + uiTextSecondary = theme().textMuted(); + uiBarBackground = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), 0xFF000000, 0.18f), + 200 + ); + Themes.ThemeEntry entry = Theme.currentEntry(); + Themes.GradientSpec healthGradient = entry != null && entry.cardGradient() != null && entry.cardGradient().enabled() + ? entry.cardGradient() + : null; + if (healthGradient != null) { + uiHealthStart = healthGradient.start(); + uiHealthEnd = healthGradient.end(); + uiHealthGradientAngle = healthGradient.angleDeg(); + } else { + uiHealthStart = theme().accent(); + uiHealthEnd = HudRenderUtil.mixColor(theme().accentSoft(), theme().textPrimary(), 0.18f); + uiHealthGradientAngle = 45.0f; + } + uiTrail = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().accentSoft(), 0.22f), + 170 + ); + uiAbsorptionStart = 0xFFFFB400; + uiAbsorptionEnd = 0xFFFFE17A; + uiAbsorptionGradientAngle = uiHealthGradientAngle + 90.0f; + uiHitOuter = HudRenderUtil.mixColor(theme().accentSoft(), 0xFFFF6767, 0.45f); + uiHitInner = HudRenderUtil.mixColor(theme().accent(), 0xFFFFC2C2, 0.22f); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiTextPrimary = text.getArgb(); + uiTextSecondary = textSecondary.getArgb(); + uiBarBackground = barBg.getArgb(); + uiHealthStart = healthStart.getArgb(); + uiHealthEnd = healthEnd.getArgb(); + uiHealthGradientAngle = 45.0f; + uiTrail = trail.getArgb(); + uiAbsorptionStart = absorptionStart.getArgb(); + uiAbsorptionEnd = absorptionEnd.getArgb(); + uiAbsorptionGradientAngle = 135.0f; + uiHitOuter = hitOuter.getArgb(); + uiHitInner = hitInner.getArgb(); + } + + private void drawBackground(Renderer2D renderer, + float x, + float y, + float width, + float height, + float radius, + float alphaFactor, + boolean glassEnabled) { + if (glassEnabled) { + renderer.roundedRect(x, y, width, height, radius, 1.0f, HudRenderUtil.glassPanelBackground(alphaFactor)); + return; + } + + int primary = HudRenderUtil.scaleAlpha(uiBgPrimary, alphaFactor); + int secondary = HudRenderUtil.scaleAlpha(uiBgSecondary, alphaFactor); + + if (isThemeMode()) { + HudRenderUtil.drawHudBackground(renderer, x, y, width, height, radius, 1.0f, primary, true); + } else { + renderer.roundedRectGradientQuad(x, y, width, height, radius, 1.0f, primary, secondary, primary, secondary); + } + + renderer.roundedRectStroke( + x, y, width, height, radius, 1.0f, + 0.35f * radius / PANEL_RADIUS, + HudRenderUtil.scaleAlpha(uiStroke, alphaFactor) + ); + } + + private void drawFace(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + LivingEntity target, + float x, + float y, + float scaleFactor, + float alphaFactor, + TotemPopSnapshot totemPopSnapshot) { + float faceSize = FACE_SIZE * scaleFactor; + float faceX = x + (FACE_X + CONTENT_X_OFFSET) * scaleFactor; + float faceY = y + (FACE_Y + CONTENT_Y_OFFSET) * scaleFactor; + float faceRadius = FACE_RADIUS * scaleFactor; + + float hurtPercent = target.hurtTime > 0 ? Math.min(1.0f, target.hurtTime / 10.0f) : 0.0f; + int red = 255; + int green = Math.round(255.0f * (1.0f - hurtPercent)); + int blue = Math.round(255.0f * (1.0f - hurtPercent)); + int tint = rgba(red, green, blue, Math.round(255.0f * alphaFactor)); + + if (target instanceof Player player) { + drawPlayerFace(player, faceX, faceY, faceSize, faceRadius, tint); + } else { + drawPlaceholderFace(renderer, textRenderer, target, faceX, faceY, faceSize, faceRadius, alphaFactor, hurtPercent); + } + + drawTotemPopBadge(renderer, textRenderer, totemPopSnapshot, faceX, faceY, faceSize, faceRadius, scaleFactor, alphaFactor); + } + + private TotemPopSnapshot resolveTotemPopSnapshot(LivingEntity target) { + if (!(target instanceof Player player)) { + return TotemPopSnapshot.empty(null); + } + return TotemPopCounter.snapshot(player.getUUID()); + } + + private void updateTotemPopAnimation(LivingEntity target, + TotemPopSnapshot snapshot, + float dt) { + UUID id = target instanceof Player player ? player.getUUID() : null; + if (id == null || !id.equals(totemPopTargetId)) { + totemPopTargetId = id; + totemPopAnimation = 0.0f; + totemPopTextPulse = 0.0f; + totemPopLastCount = 0; + } + + int count = snapshot != null ? Math.max(0, snapshot.count()) : 0; + if (count > 0 && count != totemPopLastCount) { + totemPopLastCount = count; + totemPopTextPulse = 1.0f; + } else if (count <= 0) { + totemPopLastCount = 0; + } + + float targetAnimation = snapshot != null && snapshot.visible() ? 1.0f : 0.0f; + totemPopAnimation = AnimationUtility.approach(totemPopAnimation, targetAnimation, dt, TOTEM_BADGE_ANIM_SPEED); + totemPopAnimation = AnimationUtility.snap(totemPopAnimation, targetAnimation, 0.001f); + totemPopTextPulse = AnimationUtility.approach(totemPopTextPulse, 0.0f, dt, TOTEM_BADGE_PULSE_SPEED); + totemPopTextPulse = AnimationUtility.snap(totemPopTextPulse, 0.0f, 0.001f); + } + + private void drawTotemPopBadge(Renderer2D renderer, + TextRenderer textRenderer, + TotemPopSnapshot snapshot, + float faceX, + float faceY, + float faceSize, + float faceRadius, + float scaleFactor, + float alphaFactor) { + if (snapshot == null || snapshot.count() <= 0 || totemPopAnimation <= 0.001f) { + return; + } + + float anim = AnimationUtility.easeInOutCubic(totemPopAnimation); + float visualAlpha = alphaFactor * anim; + if (visualAlpha <= 0.001f) { + return; + } + + String text = "x" + snapshot.count(); + TextRenderer badgeRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + float pulse = AnimationUtility.easeOutBack(totemPopTextPulse, 1.12f); + float groupScale = (0.86f + 0.14f * anim) * (1.0f + 0.13f * pulse); + float textScale = TOTEM_BADGE_TEXT_SCALE * scaleFactor * groupScale; + float iconSize = Math.min(faceSize * 0.38f, TOTEM_BADGE_ICON_SIZE * scaleFactor * groupScale); + float gap = TOTEM_BADGE_TEXT_GAP * scaleFactor; + float textWidth = measureText(badgeRenderer, text, textScale); + float textHeight = getTextHeight(badgeRenderer, textScale); + float groupWidth = iconSize + gap + textWidth; + float groupHeight = Math.max(iconSize, textHeight); + + float groupX = faceX + (faceSize - groupWidth) * 0.5f; + float groupY = faceY + faceSize - groupHeight - TOTEM_BADGE_BOTTOM_PAD * scaleFactor + - (1.0f - anim) * 1.4f * scaleFactor; + float iconX = groupX; + float iconY = groupY + (groupHeight - iconSize) * 0.5f; + drawItem(renderer, totemBadgeStack(), iconX, iconY, Math.max(0.1f, iconSize / 16.0f), 713, visualAlpha); + + float textX = iconX + iconSize + gap; + float textY = groupY + (groupHeight - textHeight) * 0.5f - 0.12f * scaleFactor; + int textColor = HudRenderUtil.setAlpha(totemPopTextColor.getArgb(), Math.round(255.0f * visualAlpha)); + badgeRenderer.begin(textScale, false, false); + try { + badgeRenderer.render(text, textX, textY, new RenderColor(textColor), true); + } finally { + badgeRenderer.end(); + } + } + + private void drawPlayerFace(Player player, + float x, + float y, + float size, + float radius, + int tint) { + Identifier skin = PlayerSkinResolver.resolveProfileSkin(player.getGameProfile()); + if (skin == null) return; + + Renderer2D.TEXTURE.roundedTexRect( + x, y, size, size, + radius, 1.0f, + FACE_U0, FACE_V0, FACE_U1, FACE_V1, + tint, + skin + ); + + float hatSize = size * 1.1f; + float hatOffset = (hatSize - size) * 0.5f; + Renderer2D.TEXTURE.roundedTexRect( + x - hatOffset, y - hatOffset, hatSize, hatSize, + radius * 1.05f, 1.0f, + HAT_U0, HAT_V0, HAT_U1, HAT_V1, + tint, + skin + ); + } + + private void drawPlaceholderFace(Renderer2D renderer, + TextRenderer textRenderer, + LivingEntity target, + float x, + float y, + float size, + float radius, + float alphaFactor, + float hurtPercent) { + int light = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(uiBgSecondary, uiHealthEnd, 0.28f), alphaFactor); + int dark = HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(uiBgPrimary, 0xFF000000, 0.22f), alphaFactor); + renderer.roundedRectGradient(x, y, size, size, radius, 1.0f, light, dark, 90.0f); + renderer.roundedRectStroke( + x, y, size, size, radius, 1.0f, + 0.65f * size / FACE_SIZE, + HudRenderUtil.scaleAlpha(uiStroke, alphaFactor) + ); + + String name = target.getName().getString(); + String letter = name.isEmpty() ? "?" : name.substring(0, 1).toUpperCase(Locale.ROOT); + TextRenderer faceRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + float textScale = PLACEHOLDER_TEXT_SCALE * scale.get().floatValue() * HudScale.scale( + mc.getWindow().getGuiScaledWidth(), + mc.getWindow().getGuiScaledHeight() + ); + faceRenderer.begin(textScale, false, false); + try { + float textWidth = (float) faceRenderer.getWidth(letter, false); + float textHeight = (float) faceRenderer.getHeight(false); + float tx = x + (size - textWidth) * 0.5f; + float ty = y + (size - textHeight) * 0.5f - scale.get().floatValue(); + int letterColor = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiTextPrimary, uiTextSecondary, hurtPercent * 0.35f), + alphaFactor + ); + faceRenderer.render(letter, tx, ty, new RenderColor(letterColor), false); + } finally { + faceRenderer.end(); + } + } + + private void drawContent(Renderer2D renderer, + TextRenderer textRenderer, + LivingEntity target, + float x, + float y, + float drawWidth, + float scaleFactor, + float alphaFactor, + long now, + float dt) { + float contentX = x + (FACE_X + FACE_SIZE + CONTENT_GAP + CONTENT_X_OFFSET) * scaleFactor; + float nameY = y + (NAME_Y + CONTENT_Y_OFFSET) * scaleFactor; + + PlayerHealthResolver.HealthSnapshot resolved = PlayerHealthResolver.resolve(target); + float health = Math.max(0.0f, resolved.totalHealth() - resolved.absorption()); + float maxHealth = Math.max(1.0f, target.getMaxHealth()); + float absorption = Math.max(0.0f, resolved.absorption()); + + float targetDisplayHealth = health + absorption; + displayedHealth = AnimationUtility.approach(displayedHealth, targetDisplayHealth, dt, HEALTH_SPEED); + displayedHealth = AnimationUtility.snap(displayedHealth, targetDisplayHealth, 0.02f); + if (targetDisplayHealth <= HEALTH_ZERO_CLAMP && displayedHealth <= HEALTH_ZERO_SNAP_THRESHOLD) { + displayedHealth = 0.0f; + } + + float targetHealthFraction = AnimationUtility.clamp01(health / maxHealth); + float targetAbsorptionFraction = AnimationUtility.clamp01(absorption / maxHealth); + healthAnimation = AnimationUtility.approach(healthAnimation, targetHealthFraction, dt, BAR_SPEED); + healthAnimation = AnimationUtility.snap(healthAnimation, targetHealthFraction, PROGRESS_SNAP_EPSILON); + if (targetHealthFraction > trailAnimation) { + trailAnimation = targetHealthFraction; + } + trailAnimation = AnimationUtility.approach(trailAnimation, targetHealthFraction, dt, BAR_SPEED - 0.5f); + trailAnimation = AnimationUtility.snap(trailAnimation, targetHealthFraction, PROGRESS_SNAP_EPSILON); + absorptionAnimation = AnimationUtility.approach(absorptionAnimation, targetAbsorptionFraction, dt, BAR_SPEED); + absorptionAnimation = AnimationUtility.snap(absorptionAnimation, targetAbsorptionFraction, PROGRESS_SNAP_EPSILON); + + boolean selfTarget = target == mc.player; + if (!selfTarget) { + float targetDistance = mc.player != null ? mc.player.distanceTo(target) : 0.0f; + displayedDistance = AnimationUtility.approach(displayedDistance, targetDistance, dt, DISTANCE_SPEED); + displayedDistance = AnimationUtility.snap(displayedDistance, targetDistance, 0.01f); + } + + String hpText = formatHealth(formatDisplayHealth(displayedHealth)); + String distanceText = selfTarget ? "" : formatDistance(displayedDistance) + "m"; + String moduleStatus = resolveModuleStatus(target); + String metaText = moduleStatus != null ? moduleStatus : distanceText; + float panelWidth = drawWidth - PANEL_INSET * 4f * scaleFactor; + TextRenderer labelRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + TextRenderer metaRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + boolean arcMode = isArcHealthMode(); + float hpTextScale = (arcMode ? ARC_HP_TEXT_SCALE : HP_TEXT_SCALE) * scaleFactor; + float hpWidth = measureText(labelRenderer, hpText, hpTextScale); + float contentReserve = arcMode + ? ARC_RESERVED_WIDTH * scaleFactor + : hpWidth + 12.0f * scaleFactor; + float maxNameWidth = Math.max(12.0f * scaleFactor, panelWidth - FACE_SIZE * scaleFactor - CONTENT_GAP * scaleFactor - contentReserve); + String rawName = target.getName().getString(); + String name = trimText(labelRenderer, rawName, maxNameWidth, NAME_TEXT_SCALE * scaleFactor); + String meta = trimText(metaRenderer, metaText, maxNameWidth, DISTANCE_TEXT_SCALE * scaleFactor); + float nameScale = NAME_TEXT_SCALE * scaleFactor; + float rawNameWidth = measureText(labelRenderer, rawName, nameScale); + boolean useMarquee = marquee.get() && rawNameWidth > maxNameWidth * 1.01f; + + int nameColor = HudRenderUtil.scaleAlpha(uiTextPrimary, alphaFactor); + int hpColor = HudRenderUtil.scaleAlpha(uiTextSecondary, alphaFactor); + int distanceColor = HudRenderUtil.scaleAlpha(uiTextSecondary, alphaFactor); + + if (useMarquee) { + drawScrollingText( + labelRenderer, + rawName, + contentX, + nameY, + rawNameWidth, + maxNameWidth, + nameScale, + scaleFactor, + alphaFactor, + nameColor + ); + } else { + labelRenderer.begin(nameScale, false, false); + try { + labelRenderer.render(name, contentX, nameY, new RenderColor(nameColor), false); + } finally { + labelRenderer.end(); + } + } + + if (arcMode) { + metaRenderer.begin(DISTANCE_TEXT_SCALE * scaleFactor, false, false); + try { + metaRenderer.render( + meta, + contentX, + nameY + DISTANCE_Y_OFFSET * scaleFactor, + new RenderColor(distanceColor), + false + ); + } finally { + metaRenderer.end(); + } + + drawHealthArc(renderer, textRenderer, x, y, panelWidth, scaleFactor, alphaFactor, hpText, hpColor, now); + return; + } + + labelRenderer.begin(HP_TEXT_SCALE * scaleFactor, false, false); + try { + float hpX = x + panelWidth - hpWidth + CONTENT_X_OFFSET * scaleFactor; + labelRenderer.render(hpText, hpX, nameY, new RenderColor(hpColor), false); + } finally { + labelRenderer.end(); + } + + float barX = contentX; + float barY = nameY + BAR_Y_OFFSET * scaleFactor; + float barWidth = BAR_WIDTH * scaleFactor; + float barHeight = BAR_HEIGHT * scaleFactor; + float barRadius = BAR_RADIUS * scaleFactor; + + renderer.roundedRect( + barX, barY, barWidth, barHeight, barRadius, 1.0f, + HudRenderUtil.scaleAlpha(uiBarBackground, alphaFactor) + ); + + float trailPercent = AnimationUtility.clamp01(trailAnimation); + float healthPercent = AnimationUtility.clamp01(healthAnimation); + if (trailPercent > healthPercent + 0.001f) { + float trailFade = tailFade(BAR_WIDTH * trailPercent, BAR_TAIL_FADE_LENGTH); + int trailColor = HudRenderUtil.scaleAlpha(uiTrail, alphaFactor * trailFade); + renderer.roundedProgressRectGradient( + barX, barY, barWidth, barHeight, barRadius, 1.0f, + trailPercent, false, trailColor, trailColor, 0.0f, 0.0f + ); + } + + long elapsedMs = Math.max(0L, now - waveStartMs); + + if (healthPercent > 0.0001f) { + float healthFade = tailFade(BAR_WIDTH * healthPercent, BAR_TAIL_FADE_LENGTH); + int[] healthWave = buildWaveColors(elapsedMs, 1500f, alphaFactor * healthFade, uiHealthStart, uiHealthEnd); + renderer.roundedProgressRectGradient( + barX, barY, barWidth, barHeight, barRadius, 1.0f, + healthPercent, false, healthWave[0], healthWave[2], 90.0f, 0.0f + ); + } + + float absorptionPercent = AnimationUtility.clamp01(absorptionAnimation); + if (absorptionPercent > 0.0001f) { + float absorptionFade = tailFade(BAR_WIDTH * absorptionPercent, BAR_TAIL_FADE_LENGTH); + int[] absorptionWave = buildWaveColors(elapsedMs, 1200f, alphaFactor * absorptionFade, uiAbsorptionStart, uiAbsorptionEnd); + renderer.roundedProgressRectGradient( + barX, barY, barWidth, barHeight, barRadius, 1.0f, + absorptionPercent, false, absorptionWave[0], absorptionWave[2], 90.0f, 0.0f + ); + } + } + + private String resolveModuleStatus(LivingEntity target) { + if (target == null) { + return null; + } + + AutoCrystal autoCrystal = Modules.get(AutoCrystal.class); + if (autoCrystal != null) { + String status = autoCrystal.getTargetHudStatus(target); + if (status != null && !status.isBlank()) { + return status; + } + } + + AutoAnchor autoAnchor = Modules.get(AutoAnchor.class); + if (autoAnchor != null) { + String status = autoAnchor.getTargetHudStatus(target); + if (status != null && !status.isBlank()) { + return status; + } + } + + AutoBed autoBed = Modules.get(AutoBed.class); + if (autoBed != null) { + String status = autoBed.getTargetHudStatus(target); + if (status != null && !status.isBlank()) { + return status; + } + } + + return null; + } + + private void drawScrollingText(TextRenderer renderer, + String text, + float x, + float y, + float fullWidth, + float viewWidth, + float scale, + float baseScale, + float alpha, + int color) { + float speed = BASE_NAME_SCROLL_SPEED * baseScale; + float gap = BASE_NAME_SCROLL_GAP * baseScale; + if (speed <= 0.0f) { + return; + } + + float cycleDistance = fullWidth + gap; + float cycleTime = NAME_SCROLL_PAUSE_SEC + (cycleDistance / speed); + if (cycleTime <= 0.0f) { + return; + } + + float now = Util.getMillis() / 1000.0f; + float t = now % cycleTime; + float offset = 0.0f; + if (t > NAME_SCROLL_PAUSE_SEC) { + offset = -(t - NAME_SCROLL_PAUSE_SEC) * speed; + } + + RenderColor renderColor = new RenderColor(HudRenderUtil.scaleAlpha(color, alpha)); + float fade = Math.min(viewWidth * 0.25f, BASE_NAME_FADE * baseScale); + renderer.begin(scale, false, false); + try { + renderer.renderHorizontalFadeClipped(text, x + offset, y, renderColor, x, x + viewWidth, fade, fade, false); + if (cycleDistance > viewWidth * 0.5f) { + renderer.renderHorizontalFadeClipped(text, x + offset + cycleDistance, y, renderColor, + x, x + viewWidth, fade, fade, false); + } + } finally { + renderer.end(); + } + } + + private float getTextHeight(TextRenderer renderer, float scale) { + renderer.begin(scale, false, false); + try { + return (float) renderer.getHeight(false); + } finally { + renderer.end(); + } + } + + private void drawHealthArc(Renderer2D renderer, + TextRenderer textRenderer, + float x, + float y, + float panelWidth, + float scaleFactor, + float alphaFactor, + String hpText, + int hpTextColor, + long now) { + float centerX = x + panelWidth - ARC_RIGHT_INSET * scaleFactor + CONTENT_X_OFFSET * scaleFactor; + float centerY = y + ARC_CENTER_Y * scaleFactor + CONTENT_Y_OFFSET * scaleFactor; + float radius = ARC_RADIUS * scaleFactor; + float thickness = ARC_THICKNESS * scaleFactor; + float softness = Math.max(0.35f, 0.45f * scaleFactor); + + int ringBackground = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiBarBackground, 0xFF000000, 0.34f), + alphaFactor * 0.55f + ); + renderer.arcStroke(centerX, centerY, radius, thickness, 0.0f, 360.0f, softness, ringBackground); + + float healthPercent = AnimationUtility.clamp01(healthAnimation); + + float healthSweep = 360.0f * healthPercent; + if (healthSweep > 0.001f) { + float healthFade = tailFade(healthSweep, ARC_TAIL_FADE_DEGREES); + drawAnimatedArcGradient( + renderer, + centerX, + centerY, + radius, + thickness, + 0.0f, + healthSweep, + softness, + alphaFactor * healthFade, + uiHealthStart, + uiHealthEnd, + uiHealthGradientAngle, + 1700f, + now - waveStartMs, + 0.0f + ); + } + + float absorptionPercent = AnimationUtility.clamp01(absorptionAnimation); + float absorptionSweep = 360.0f * absorptionPercent; + if (absorptionSweep > 0.001f) { + float absorptionThickness = Math.max(1.6f * scaleFactor, thickness * 0.9f); + float absorptionFade = tailFade(absorptionSweep, ARC_TAIL_FADE_DEGREES); + drawAnimatedArcGradient( + renderer, + centerX, + centerY, + radius, + absorptionThickness, + 0.0f, + absorptionSweep, + softness, + alphaFactor * absorptionFade, + uiAbsorptionStart, + uiAbsorptionEnd, + uiAbsorptionGradientAngle, + 1300f, + now - waveStartMs, + 1.35f + ); + } + + TextRenderer hpRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + float hpScale = ARC_HP_TEXT_SCALE * scaleFactor; + hpRenderer.begin(hpScale, false, false); + try { + float textWidth = (float) hpRenderer.getWidth(hpText, false); + float textHeight = (float) hpRenderer.getHeight(false); + hpRenderer.render( + hpText, + centerX - textWidth * 0.5f, + centerY - textHeight * 0.5f, + new RenderColor(hpTextColor), + false + ); + } finally { + hpRenderer.end(); + } + } + + private void drawEquipment(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + EquipmentLayout layout, + float x, + float y, + float panelWidth, + float equipmentHeight, + float scaleFactor, + float alphaFactor, + boolean glassEnabled) { + if (ctx == null || layout.entries().isEmpty() || alphaFactor <= 0.01f) { + return; + } + + float rowX = x + Math.max(0.0f, (panelWidth - layout.width()) * 0.5f); + float rowY = y + Math.max(0.0f, equipmentHeight - layout.height()) + (EQUIPMENT_ROW_Y_OFFSET * scaleFactor); + + float slotHeight = EQUIPMENT_SLOT * scaleFactor; + float radius = 2.2f * scaleFactor; + List itemTasks = new ArrayList<>(layout.entries().size()); + int slotFillLeft = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiBarBackground, uiBgPrimary, 0.34f), + alphaFactor + ); + int slotFillRight = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiBgSecondary, uiBarBackground, 0.24f), + alphaFactor * 0.96f + ); + int slotStroke = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiStroke, uiBgSecondary, 0.10f), + alphaFactor * 0.46f + ); + int slotHighlight = HudRenderUtil.scaleAlpha( + HudRenderUtil.mixColor(uiTextPrimary, uiBgPrimary, 0.88f), + alphaFactor * 0.08f + ); + float itemScale = Math.max(0.35f, (slotHeight - 3.0f * scaleFactor) / 16.0f); + float cursorX = rowX; + for (EquipmentEntry entry : layout.entries()) { + if (glassEnabled) { + drawGlassCard(cursorX, rowY, entry.width(), slotHeight, radius, scaleFactor, alphaFactor); + renderer.roundedRect(cursorX, rowY, entry.width(), slotHeight, radius, 1.0f, + HudRenderUtil.glassSmallBackground(alphaFactor)); + } else { + renderer.roundedRectGradientQuad( + cursorX, rowY, entry.width(), slotHeight, radius, 1.0f, + slotFillLeft, slotFillRight, slotFillRight, slotFillLeft + ); + renderer.roundedRectStroke(cursorX, rowY, entry.width(), slotHeight, radius, 1.0f, 0.5f * scaleFactor, slotStroke); + renderer.roundedRect( + cursorX + 0.75f * scaleFactor, + rowY + 0.75f * scaleFactor, + Math.max(0.0f, entry.width() - 1.5f * scaleFactor), + Math.max(0.6f, 0.75f * scaleFactor), + Math.max(0.5f, radius * 0.45f), + 1.0f, + slotHighlight + ); + } + + if (entry.stack() != null && !entry.stack().isEmpty()) { + float itemX = cursorX + 1.5f * scaleFactor; + float itemY = rowY + (slotHeight - 16.0f * itemScale) * 0.5f; + itemTasks.add(new EquipmentRenderTask(entry.stack(), itemX, itemY, itemScale)); + } else { + drawEmptyEquipmentCross(renderer, cursorX, rowY, entry.width(), slotHeight, scaleFactor, alphaFactor); + } + + cursorX += entry.width() + EQUIPMENT_GAP * scaleFactor; + } + if (!itemTasks.isEmpty()) { + int seed = 0; + for (EquipmentRenderTask task : itemTasks) { + drawItem(renderer, task.stack(), task.x(), task.y(), task.scale(), seed++, alphaFactor); + } + } + } + + private void drawEffects(Renderer2D renderer, + TextRenderer textRenderer, + EffectLayout layout, + float x, + float y, + float panelWidth, + float scaleFactor, + float alphaFactor, + boolean glassEnabled) { + if (layout.chips().isEmpty() || alphaFactor <= 0.01f) { + return; + } + + float chipHeight = EFFECTS_CHIP_HEIGHT * scaleFactor; + float rowX = x + (panelWidth - layout.width()) * 0.5f; + float rowY = y + (layout.height() - chipHeight) * 0.5f; + float radius = EFFECTS_CHIP_RADIUS * scaleFactor; + int fill = glassEnabled + ? HudRenderUtil.glassSmallBackground(alphaFactor) + : HudRenderUtil.scaleAlpha(HudRenderUtil.mixColor(uiBgPrimary, uiBgSecondary, 0.4f), alphaFactor); + int stroke = glassEnabled + ? 0 + : HudRenderUtil.scaleAlpha(uiStroke, alphaFactor * 0.5f); + int iconColor = HudRenderUtil.scaleAlpha(uiTextPrimary, alphaFactor); + int textColor = HudRenderUtil.scaleAlpha(uiTextSecondary, alphaFactor); + + TextRenderer chipRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + float textScale = EFFECTS_TEXT_SCALE * scaleFactor; + float textHeight = 0.0f; + chipRenderer.begin(textScale, false, false); + try { + textHeight = (float) chipRenderer.getHeight(false); + float cursorX = rowX; + for (EffectChip chip : layout.chips()) { + if (glassEnabled) { + drawGlassCard(cursorX, rowY, chip.width(), chipHeight, radius, scaleFactor, alphaFactor); + } + renderer.roundedRect(cursorX, rowY, chip.width(), chipHeight, radius, 1.0f, fill); + if (!glassEnabled) { + renderer.roundedRectStroke(cursorX, rowY, chip.width(), chipHeight, radius, 1.0f, 0.45f * scaleFactor, stroke); + } + + float iconX = cursorX + EFFECTS_CHIP_PAD_X * scaleFactor; + float iconY = rowY + (chipHeight - EFFECTS_ICON_SIZE * scaleFactor) * 0.5f; + if (chip.iconId() != null) { + Renderer2D.TEXTURE.roundedTexRect( + iconX, + iconY, + EFFECTS_ICON_SIZE * scaleFactor, + EFFECTS_ICON_SIZE * scaleFactor, + 2.0f * scaleFactor, + 1.0f, + iconColor, + chip.iconId() + ); + } + + if (!chip.text().isEmpty()) { + float textX = iconX + EFFECTS_ICON_SIZE * scaleFactor + EFFECTS_TEXT_GAP * scaleFactor; + float textY = rowY + (chipHeight - textHeight) * 0.5f; + chipRenderer.render(chip.text(), textX, textY, new RenderColor(textColor), false); + } + + cursorX += chip.width() + EFFECTS_CHIP_GAP * scaleFactor; + } + } finally { + chipRenderer.end(); + } + } + + private void drawHitPulse(Renderer2D renderer, + float x, + float y, + float width, + float height, + float radius, + float alphaFactor, + float pulse) { + if (!hitAnim.get() || pulse <= 0.001f) return; + + float scaledPulse = pulse * hitPulse.get().floatValue(); + float spread = 6.0f * (0.65f + scaledPulse); + int outerColor = HudRenderUtil.scaleAlpha(uiHitOuter, 0.58f * scaledPulse * alphaFactor); + int innerColor = HudRenderUtil.scaleAlpha(uiHitInner, 0.92f * scaledPulse * alphaFactor); + + renderer.roundedRectStroke( + x - spread * 0.4f, + y - spread * 0.4f, + width + spread * 0.8f, + height + spread * 0.8f, + radius + spread * 0.15f, + 1.0f, + 0.85f + scaledPulse, + outerColor + ); + renderer.roundedRectStroke(x, y, width, height, radius, 1.0f, 0.9f + scaledPulse, innerColor); + } + + private void drawBlur(float x, float y, float width, float height, float radius, int tintRgb, float alphaFactor) { + if (!hasEffect() || width <= 0.0f || height <= 0.0f) return; + float quality = hud.getBlurRadius(); + float brightness = 1.0f; + float alpha = (blurAlpha.get() / 255.0f) * alphaFactor; + Renderer2D.COLOR.blurRect(x, y, width, height, radius, quality, brightness, alpha, 0xFFFFFF); + } + + private void drawGlass(float x, float y, float width, float height, float radius, float alphaFactor) { + float blurStrength = (blurAlpha.get() / 255.0f) * alphaFactor; + float glassAlpha = alphaFactor; + float glassScale = PANEL_RADIUS <= 0.0f ? 1.0f : radius / PANEL_RADIUS; + HudRenderUtil.drawLiquidGlass(x, y, width, height, radius, glassScale, true, blurStrength, glassAlpha); + } + + private void drawGlassCard(float x, + float y, + float width, + float height, + float radius, + float scaleFactor, + float alphaFactor) { + float blurStrength = (blurAlpha.get() / 255.0f) * alphaFactor; + HudRenderUtil.drawLiquidGlass(x, y, width, height, radius, scaleFactor, true, blurStrength, alphaFactor); + } + + private void drawItem(Renderer2D renderer, + ItemStack stack, + float x, + float y, + float itemScale, + int seed, + float alphaFactor) { + if (renderer == null || stack == null || stack.isEmpty() || alphaFactor <= 0.001f) { + return; + } + + double previousAlpha = renderer.getAlpha(); + renderer.setAlpha(previousAlpha * AnimationUtility.clamp01(alphaFactor)); + try { + renderer.item(stack, x, y, itemScale, seed, Renderer2D.ITEM_OVERLAY_NONE, null); + } finally { + renderer.setAlpha(previousAlpha); + } + } + + private float computeHitPulse(long now) { + if (displayTarget == null) return 0.0f; + Long hitAt = HIT_FLASH_MS_BY_ID.get(displayTarget.getId()); + if (hitAt == null) return 0.0f; + + float elapsed = now - hitAt; + if (elapsed >= HIT_FLASH_MS) { + HIT_FLASH_MS_BY_ID.remove(displayTarget.getId()); + return 0.0f; + } + float t = 1.0f - elapsed / HIT_FLASH_MS; + return AnimationUtility.easeOutCubic(AnimationUtility.clamp01(t)); + } + + private EquipmentLayout buildEquipmentLayout(LivingEntity target, + boolean preview, + TextRenderer textRenderer, + float scale) { + if (!isEquipmentEnabled() || target == null || textRenderer == null || scale <= 0.0f) { + return new EquipmentLayout(List.of(), 0.0f, 0.0f); + } + + List entries = collectEquipmentEntries(target, preview, textRenderer, scale); + if (entries.isEmpty()) { + return new EquipmentLayout(List.of(), 0.0f, 0.0f); + } + + float totalWidth = 0.0f; + for (int i = 0; i < entries.size(); i++) { + totalWidth += entries.get(i).width(); + if (i < entries.size() - 1) { + totalWidth += EQUIPMENT_GAP * scale; + } + } + float totalHeight = EQUIPMENT_TOP_GAP * scale + EQUIPMENT_SLOT * scale + EQUIPMENT_BOTTOM_PAD * scale; + return new EquipmentLayout(entries, totalWidth, totalHeight); + } + + private EffectLayout buildEffectLayout(LivingEntity target, + boolean preview, + TextRenderer textRenderer, + float scale) { + if (!isEffectsEnabled() || target == null || textRenderer == null || scale <= 0.0f) { + return new EffectLayout(List.of(), 0.0f, 0.0f); + } + + List effects = collectTargetEffects(target, preview); + if (effects.isEmpty()) { + return new EffectLayout(List.of(), 0.0f, 0.0f); + } + + List chips = new ArrayList<>(); + TextRenderer chipRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, textRenderer); + float textScale = EFFECTS_TEXT_SCALE * scale; + int limit = Math.min(4, effects.size()); + float totalWidth = 0.0f; + + for (int i = 0; i < limit; i++) { + MobEffectInstance inst = effects.get(i); + Identifier iconId = resolveEffectIcon(inst); + String text = compactEffectText(target.getId(), inst); + float width = EFFECTS_CHIP_PAD_X * 2.0f * scale + EFFECTS_ICON_SIZE * scale; + if (!text.isEmpty()) { + width += EFFECTS_TEXT_GAP * scale + measureText(chipRenderer, text, textScale); + } + chips.add(new EffectChip(inst, iconId, text, width)); + totalWidth += width; + if (i < limit - 1) { + totalWidth += EFFECTS_CHIP_GAP * scale; + } + } + + float totalHeight = (EFFECTS_CHIP_HEIGHT + EFFECTS_PANEL_GAP) * scale; + return new EffectLayout(chips, totalWidth, totalHeight); + } + + private List collectEquipmentEntries(LivingEntity target, + boolean preview, + TextRenderer textRenderer, + float scale) { + List entries = new ArrayList<>(); + + if (preview) { + ItemStack sword = new ItemStack(Items.DIAMOND_SWORD); + ItemStack apple = new ItemStack(Items.GOLDEN_APPLE); + ItemStack head = new ItemStack(Items.DIAMOND_HELMET); + ItemStack chest = new ItemStack(Items.DIAMOND_CHESTPLATE); + ItemStack legs = new ItemStack(Items.DIAMOND_LEGGINGS); + ItemStack feet = new ItemStack(Items.DIAMOND_BOOTS); + + sword.setDamageValue(Math.max(1, sword.getMaxDamage() / 6)); + head.setDamageValue(Math.max(1, head.getMaxDamage() / 3)); + chest.setDamageValue(Math.max(1, chest.getMaxDamage() / 2)); + legs.setDamageValue(Math.max(1, legs.getMaxDamage() / 4)); + feet.setDamageValue(Math.max(1, feet.getMaxDamage() / 5)); + + addEquipmentEntry(entries, sword, scale); + addEquipmentEntry(entries, apple, scale); + addEquipmentEntry(entries, head, scale); + addEquipmentEntry(entries, chest, scale); + addEquipmentEntry(entries, legs, scale); + addEquipmentEntry(entries, feet, scale); + return entries; + } + + addEquipmentEntry(entries, target.getMainHandItem(), scale); + addEquipmentEntry(entries, target.getOffhandItem(), scale); + addEquipmentEntry(entries, target.getItemBySlot(EquipmentSlot.HEAD), scale); + addEquipmentEntry(entries, target.getItemBySlot(EquipmentSlot.CHEST), scale); + addEquipmentEntry(entries, target.getItemBySlot(EquipmentSlot.LEGS), scale); + addEquipmentEntry(entries, target.getItemBySlot(EquipmentSlot.FEET), scale); + return entries; + } + + private void addEquipmentEntry(List entries, + ItemStack stack, + float scale) { + float baseWidth = EQUIPMENT_SLOT * scale; + entries.add(new EquipmentEntry(stack == null ? ItemStack.EMPTY : stack, baseWidth)); + } + + private List collectTargetEffects(LivingEntity target, boolean preview) { + if (target == null) { + return List.of(); + } + if (target == mc.player && !effectsSelf.get() && !preview) { + return List.of(); + } + if (target instanceof Player player) { + StatusEffectView.sync(mc); + List effects = StatusEffectView.collectHudEffects(player); + if (!effects.isEmpty() || !preview) { + return effects; + } + } else { + List direct = new ArrayList<>(target.getActiveEffects()); + if (!direct.isEmpty()) { + return direct; + } + } + + if (!preview) { + return List.of(); + } + + List sample = new ArrayList<>(); + sample.add(new MobEffectInstance(net.minecraft.world.effect.MobEffects.STRENGTH, 2450, 1)); + sample.add(new MobEffectInstance(net.minecraft.world.effect.MobEffects.FIRE_RESISTANCE, 1640, 0)); + sample.add(new MobEffectInstance(net.minecraft.world.effect.MobEffects.RESISTANCE, 920, 0)); + return sample; + } + + private Identifier resolveEffectIcon(MobEffectInstance inst) { + if (inst == null) { + return null; + } + return inst.getEffect().unwrapKey() + .map(key -> Identifier.fromNamespaceAndPath("minecraft", "textures/mob_effect/" + key.identifier().getPath() + ".png")) + .orElse(null); + } + + private String compactEffectText(int entityId, MobEffectInstance inst) { + if (inst == null) { + return ""; + } + String duration = formatEffectDuration(entityId, inst); + if (!duration.isEmpty()) { + return duration; + } + int level = inst.getAmplifier() + 1; + return level > 1 ? roman(level) : ""; + } + + private String formatEffectDuration(int entityId, MobEffectInstance inst) { + if (inst == null) { + return ""; + } + if (StatusEffectTracker.shouldHideDuration(entityId, inst.getEffect())) { + return ""; + } + int ticks = inst.getDuration(); + if (ticks < 0 || ticks >= 32767) { + return ""; + } + int totalSeconds = Math.max(0, ticks / 20); + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + return seconds < 10 ? minutes + ":0" + seconds : minutes + ":" + seconds; + } + + private String roman(int n) { + return switch (n) { + case 1 -> "I"; + case 2 -> "II"; + case 3 -> "III"; + case 4 -> "IV"; + case 5 -> "V"; + default -> Integer.toString(n); + }; + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + private boolean isBlurEffect() { + return EFFECT_BLUR.equals(bgEffect.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean isEffectsEnabled() { + return !EFFECTS_OFF.equals(effectsMode.get()); + } + + private boolean isEquipmentEnabled() { + return !EQUIPMENT_OFF.equals(equipmentMode.get()); + } + + private boolean isArcHealthMode() { + return HP_MODE_ARC.equals(hpMode.get()); + } + + private void drawEmptyEquipmentCross(Renderer2D renderer, + float x, + float y, + float width, + float height, + float scaleFactor, + float alphaFactor) { + float inset = Math.max(2.0f * scaleFactor, width * 0.28f); + float stroke = Math.max(0.6f, 0.85f * scaleFactor); + int color = HudRenderUtil.scaleAlpha(uiTextSecondary, alphaFactor); + renderer.line(x + inset, y + inset, x + width - inset, y + height - inset, color); + renderer.line(x + width - inset, y + inset, x + inset, y + height - inset, color); + if (stroke > 1.0f) { + float nudge = stroke * 0.35f; + renderer.line(x + inset + nudge, y + inset, x + width - inset + nudge, y + height - inset, color); + renderer.line(x + width - inset - nudge, y + inset, x + inset - nudge, y + height - inset, color); + } + } + + private record EquipmentEntry(ItemStack stack, float width) { + } + + private record EquipmentLayout(List entries, float width, float height) { + } + + private record EquipmentRenderTask(ItemStack stack, float x, float y, float scale) { + } + + private record EffectChip(MobEffectInstance effect, Identifier iconId, String text, float width) { + } + + private record EffectLayout(List chips, float width, float height) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TimerIndicator.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TimerIndicator.java new file mode 100644 index 0000000..63514c3 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/TimerIndicator.java @@ -0,0 +1,196 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.util.Mth; +import dev.loki.lovisual.config.values.BooleanValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.config.values.RGBAColorValue; +import dev.loki.lovisual.config.values.RGBColorValue; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.module.Modules; +import dev.loki.lovisual.features.module.modules.movement.Timer; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "timer_indicator", + displayName = "Timer", + enabledByDefault = false, + order = 190 +) +public final class TimerIndicator extends DraggableHudElement { + + { + defaultLayout(1242.2065f, 889.5111f); + } + + + private static final float TH_BASE_W = 60f; + private static final float TH_BASE_H = 10f; + private static final float TH_RADIUS = 3f; + + private final HudGlobalConfig hud = HudGlobalConfig.get(); + + private final NumberValue scale = num("scale", 1.0, 0.5, 5.0); + private final BooleanValue alwaysShow = bool("always_show", true); + private final BooleanValue syncTheme = bool("sync_theme", true); + private final BooleanValue blur = bool("blur", false); + private final NumberValue blurAlpha = visibleWhen(num("blur_alpha", 140, 0, 255), blur::get); + private final NumberValue blurBgAlpha = visibleWhen(num("blur_bg_alpha", 128, 0, 255), blur::get); + private final BooleanValue gradient = visibleWhen(bool("gradient", true), syncTheme::get); + private final RGBAColorValue bgColor = visibleWhen(color("bg", "#B012151B"), () -> !syncTheme.get()); + private final RGBColorValue barStart = visibleWhen(colorNoAlpha("bar_start", "#5CC8E7"), () -> !syncTheme.get()); + private final RGBColorValue barEnd = visibleWhen(colorNoAlpha("bar_end", "#55FFFF"), () -> !syncTheme.get()); + private final RGBColorValue textColor = visibleWhen(colorNoAlpha("text_color", "#FFFFFF"), () -> !syncTheme.get()); + + private static String percentText(float value) { + int pct = Math.round(Mth.clamp(value, 0f, 1f) * 100f); + if (pct >= 100) return "100%"; + return pct + "%"; + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 42f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + boolean forced = alwaysShow.get(); + boolean timerReady = isTimerIndicatorActive(); + if (!preview && !forced && (!isEnabled() || !timerReady)) { + width = 0f; + height = 0f; + return; + } + + float charge = Timer.getEnergy01(); + renderThunder(renderer, textRenderer, screenW, screenH, charge); + } + + private void renderThunder(Renderer2D renderer, + TextRenderer textRenderer, + int screenW, + int screenH, + float charge) { + float baseScale = HudScale.scale(screenW, screenH) * (hud.getFontSize() / 18f); + float drawScale = baseScale * scale.get().floatValue(); + + float boxW = TH_BASE_W * drawScale; + float boxH = TH_BASE_H * drawScale; + float radius = TH_RADIUS * drawScale; + + boolean useSync = syncTheme.get(); + boolean blurEnabled = blur.get(); + + int bg = resolveBgColor(useSync, blurEnabled); + if (blurEnabled) { + drawBlur(x, y, boxW, boxH, radius, bg); + } + + HudRenderUtil.drawHudBackground(renderer, x, y, boxW, boxH, radius, 1.0f, bg, useSync && gradient.get()); + + int start = resolveBarStart(useSync); + int end = resolveBarEnd(useSync); + float fillW = boxW * Mth.clamp(charge, 0f, 1f); + if (fillW > 0.5f) { + renderer.roundedRectGradient(x, y, fillW, boxH, radius, 1.0f, start, end, 0f); + } + + String text = percentText(charge); + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer font = Fonts.renderer("Iosevka", FontInfo.Type.Bold, fallback); + float textScale = drawScale * 0.9f; + font.begin(textScale, false, false); + float textW = (float) font.getWidth(text, false); + float textH = (float) font.getHeight(false); + float textX = x + (boxW - textW) * 0.5f; + float textY = y + (boxH - textH) * 0.5f; + font.render(text, textX, textY, new RenderColor(resolveTextColor(useSync)), false); + font.end(); + + width = boxW; + height = boxH; + } + + private int resolveBgColor(boolean useSync, boolean blurEnabled) { + if (useSync) { + if (blurEnabled) { + int alpha = blurBgAlpha.get(); + return (alpha << 24) | (theme().windowBg() & 0x00FFFFFF); + } + return theme().windowBg(); + } + + if (!blurEnabled) { + return bgColor.getArgb(); + } + + int alpha = blurBgAlpha.get(); + return (alpha << 24) | (bgColor.getArgb() & 0x00FFFFFF); + } + + private int resolveBarStart(boolean useSync) { + if (useSync) { + return HudRenderUtil.setAlpha(theme().accent(), 0xFF); + } + return (barStart.getArgb() & 0x00FFFFFF) | 0xFF000000; + } + + private int resolveBarEnd(boolean useSync) { + if (useSync) { + return HudRenderUtil.setAlpha(theme().accentSoft(), 0xFF); + } + return (barEnd.getArgb() & 0x00FFFFFF) | 0xFF000000; + } + + private int resolveTextColor(boolean useSync) { + if (useSync) { + return theme().textPrimary(); + } + return (textColor.getArgb() & 0x00FFFFFF) | 0xFF000000; + } + + private void drawBlur(float x, float y, float w, float h, float radius, int tintRgb) { + float quality = hud.getBlurRadius(); + float brightness = 1.0f; + float alpha = blurAlpha.get() / 255f; + Renderer2D.COLOR.blurRect(x, y, w, h, radius, quality, brightness, alpha, 0xFFFFFF); + } + + private boolean isTimerIndicatorActive() { + Timer timer = Modules.get(Timer.class); + if (timer == null || !timer.isEnabled()) return false; + Timer.Mode mode = timer.getMode(); + return mode != null && mode != Timer.Mode.NORMAL; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Tps.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Tps.java new file mode 100644 index 0000000..bf80d6d --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Tps.java @@ -0,0 +1,334 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.values.*; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.features.gui.hud.script.CompactHudStatModel; +import dev.loki.lovisual.features.gui.hud.script.ScriptableHudStatWidget; +import dev.loki.lovisual.features.gui.hud.script.ScriptedCompactHudStatRenderer; +import dev.loki.lovisual.render.engine.animation.AnimationUtility; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.player.NetworkStatsUtil; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "tps", + displayName = "TPS", + enabledByDefault = false, + order = 160 +) +public final class Tps extends DraggableHudElement implements ScriptableHudStatWidget { + + private static final Identifier TPS_ICON = Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/tps.png"); + private static final float SCALE_MULT = 0.68f; + private static final float BOX_HEIGHT = 20f; + private static final float BOX_RADIUS = 5f; + private static final float BOX_SOFTNESS = 1.0f; + private static final float STROKE_WIDTH = 0.55f; + private static final float ICON_SIZE = 11f; + private static final float ICON_X = 5f; + private static final float DIVIDER_X = 21f; + private static final float DIVIDER_Y = 4f; + private static final float DIVIDER_WIDTH = 0.6f; + private static final float TEXT_LEFT_X = 26f; + private static final float VALUE_UNIT_GAP = 2f; + private static final float EXTRA_GAP = 5f; + private static final float RIGHT_PAD = 5f; + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + private static final String EFFECT_GLASS = "Glass"; + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final BooleanValue extra = bool("tps_extra", false); + private final NumberValue scale = num("tps_scale", 2.37, 0.5, 5.0); + private final ModeValue colorMode = mode("tps_color_mode", "color_mode", "Theme", new String[]{COLOR_THEME, COLOR_CUSTOM}); + private final RGBColorValue iconColor = visibleWhen(colorNoAlpha("tps_icon_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue valueColor = visibleWhen(colorNoAlpha("tps_value_color", "#FFFFFF"), this::isCustomMode); + private final RGBColorValue metaColor = visibleWhen(colorNoAlpha("tps_meta_color", "#9B9B9B"), this::isCustomMode); + private final RGBColorValue extraColor = visibleWhen(colorNoAlpha("tps_extra_color", "#BEBEBE"), this::isCustomMode); + private final ModeValue bgEffect = mode("tps_bg_effect", "bg_effect", "Blur", new String[]{EFFECT_NONE, EFFECT_BLUR, EFFECT_GLASS}); + private final RGBAColorValue bg = visibleWhen(color("tps_bg", "#F7343434"), () -> isCustomMode() && !isGlassEffect()); + private final RGBAColorValue bg2 = visibleWhen(color("tps_bg_secondary", "#F7161616"), () -> isCustomMode() && !isGlassEffect()); + private final RGBColorValue stroke = visibleWhen(colorNoAlpha("tps_stroke", "#5A5A5A"), () -> isCustomMode() && !isGlassEffect()); + private final NumberValue bgAlpha = visibleWhen(num("tps_bg_alpha", 133, 0, 255), () -> isThemeMode() && !isGlassEffect()); + private final NumberValue blurAlpha = visibleWhen(num("tps_blur_alpha", 255, 0, 255), this::hasEffect); + private final EnumValue labelEffect = + enumSetting("tps_label_effect", HudTextEffects.Effect.NONE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue labelEffectSpeed = + visibleWhen(num("tps_label_effect_speed", 18, 1, 60), + () -> labelEffect.get() != HudTextEffects.Effect.NONE); + private final CompactHudStatModel scriptModel = new CompactHudStatModel("tps"); + private final StringBuilder tpsBuilder = new StringBuilder(16); + private final StringBuilder extraBuilder = new StringBuilder(16); + private int lastTpsTenth = Integer.MIN_VALUE; + private int lastExtraHundredth = Integer.MIN_VALUE; + private boolean lastExtra; + private String cachedValue = "20.0"; + private String cachedExtra = ""; + private float displayValueWidth = -1f; + private float displayExtraWidth = -1f; + private int uiBgPrimary; + private int uiBgSecondary; + private int uiStroke; + private int uiIconColor; + private int uiValueColor; + private int uiMetaColor; + private int uiExtraColor; + + private static void appendOneDecimal(StringBuilder sb, int tenth) { + if (tenth < 0) { + sb.append('-'); + tenth = -tenth; + } + int intPart = tenth / 10; + int frac = tenth - intPart * 10; + sb.append(intPart).append('.').append((char) ('0' + frac)); + } + + private static void appendTwoDecimals(StringBuilder sb, int hundredth) { + if (hundredth < 0) { + sb.append('-'); + hundredth = -hundredth; + } + int intPart = hundredth / 100; + int frac = hundredth - intPart * 100; + sb.append(intPart) + .append('.') + .append((char) ('0' + (frac / 10))) + .append((char) ('0' + (frac % 10))); + } + + private static float smoothWidth(float current, float target) { + if (current < 0.0f) return target; + if (target >= current) return target; + float next = AnimationUtility.approach(current, target, AnimationUtility.deltaTime(), 12.0f); + return AnimationUtility.snap(next, target, 0.25f); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = 16f; + this.y = 60f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (mc == null && !preview) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + if (!preview && !isEnabled()) { + width = 0f; + height = 0f; + scriptModel.setVisible(false); + return; + } + + float tps = NetworkStatsUtil.getTps(mc); + boolean showExtra = extra.get(); + float clientTps = showExtra ? NetworkStatsUtil.getClientTps(mc) : 0f; + updateTpsStrings(tps, clientTps, showExtra); + updatePalette(); + + TextRenderer fallback = textRenderer != null ? textRenderer : TextRenderer.get(); + TextRenderer valueRenderer = Fonts.renderer("Onest", FontInfo.Type.Regular, fallback); + TextRenderer metaRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, valueRenderer); + + float drawScale = HudScale.scale(screenW, screenH) + * (hud.getFontSize() / 18f) + * SCALE_MULT + * scale.get().floatValue(); + + valueRenderer.begin(drawScale, true, false); + float valueW = (float) valueRenderer.getWidth(cachedValue, false); + float valueH = (float) valueRenderer.getHeight(false); + valueRenderer.end(); + + metaRenderer.begin(drawScale, true, false); + float unitW = (float) metaRenderer.getWidth("TPS", false); + float unitH = (float) metaRenderer.getHeight(false); + float extraW = showExtra ? (float) metaRenderer.getWidth(cachedExtra, false) : 0f; + metaRenderer.end(); + + float boxH = BOX_HEIGHT * drawScale; + float iconSize = ICON_SIZE * drawScale; + float valueX = x + TEXT_LEFT_X * drawScale; + float unitGap = VALUE_UNIT_GAP * drawScale; + float extraGap = EXTRA_GAP * drawScale; + displayValueWidth = smoothWidth(displayValueWidth, valueW); + displayExtraWidth = showExtra ? smoothWidth(displayExtraWidth, extraW) : 0.0f; + float boxW = (valueX - x) + + displayValueWidth + + unitGap + + unitW + + (showExtra ? extraGap + displayExtraWidth : 0f) + + RIGHT_PAD * drawScale; + width = boxW; + height = boxH; + + float baseX = x; + float baseY = y; + float iconX = baseX + ICON_X * drawScale; + float iconY = baseY + (boxH - iconSize) * 0.5f; + float dividerX = baseX + DIVIDER_X * drawScale; + float dividerY = baseY + DIVIDER_Y * drawScale; + float dividerW = Math.max(0.5f, DIVIDER_WIDTH * drawScale); + float dividerH = Math.max(0.5f, boxH - 2f * DIVIDER_Y * drawScale); + float textY = baseY + (boxH - Math.max(valueH, unitH)) * 0.5f; + float unitX = valueX + displayValueWidth + unitGap; + float extraX = unitX + unitW + extraGap; + + int resolvedIconColor = uiIconColor; + boolean useLabelEffect = labelEffect.get() != HudTextEffects.Effect.NONE; + float time = useLabelEffect ? (float) (Util.getMillis() / 1000.0) : 0.0f; + if (useLabelEffect) { + resolvedIconColor = HudTextEffects.animatedColor(resolvedIconColor, labelEffect.get(), + labelEffectSpeed.get(), time, 0.0f); + } + + float radius = BOX_RADIUS * drawScale; + scriptModel.setVisible(true); + scriptModel.setRoot(baseX, baseY, boxW, boxH, radius, drawScale); + scriptModel.background().set(bgEffect.get(), isThemeMode(), blurAlpha.get() / 255.0f, + uiBgPrimary, uiBgSecondary, uiStroke, Math.max(0.5f, STROKE_WIDTH * drawScale), BOX_SOFTNESS); + scriptModel.icon().texture(TPS_ICON.toString(), iconX - baseX, iconY - baseY, iconSize, iconSize, resolvedIconColor); + scriptModel.divider().set(dividerX - baseX, dividerY - baseY, dividerW, dividerH, + HudRenderUtil.setAlpha(uiMetaColor, 0x52)); + scriptModel.value().set(cachedValue, "Onest", drawScale, valueX - baseX, textY - baseY, displayValueWidth, uiValueColor); + scriptModel.unit().set("TPS", "OnestMedium", drawScale, unitX - baseX, textY - baseY, unitW, uiMetaColor); + if (showExtra) { + scriptModel.extra().set(cachedExtra, "OnestMedium", drawScale, extraX - baseX, textY - baseY, displayExtraWidth, uiExtraColor); + } else { + scriptModel.extra().hidden(); + } + scriptModel.animation().setLabelEffect(labelEffect.get().name(), labelEffectSpeed.get(), time) + .setDigitAnimation(false, "", 1.0f, 0.0f); + scriptModel.data("tps", tps) + .data("clientTps", clientTps) + .data("showExtra", showExtra) + .data("valueText", cachedValue) + .data("unitText", "TPS") + .data("extraText", cachedExtra); + ScriptedCompactHudStatRenderer.INSTANCE.render(scriptModel, renderer, fallback, ctx, tickDelta); + } + + @Override + public boolean supportsWidgetAnchoring() { + return true; + } + + @Override + public CompactHudStatModel compactStatModel() { + return scriptModel; + } + + private void updateTpsStrings(float tps, float extraTps, boolean extraEnabled) { + int tenth = Math.round(tps * 10f); + int hundredth = Math.round(extraTps * 100f); + if (extraEnabled == lastExtra + && tenth == lastTpsTenth + && (!extraEnabled || hundredth == lastExtraHundredth)) { + return; + } + + lastExtra = extraEnabled; + lastTpsTenth = tenth; + lastExtraHundredth = hundredth; + + tpsBuilder.setLength(0); + appendOneDecimal(tpsBuilder, tenth); + cachedValue = tpsBuilder.toString(); + + if (extraEnabled) { + extraBuilder.setLength(0); + extraBuilder.append('['); + appendTwoDecimals(extraBuilder, hundredth); + extraBuilder.append(']'); + cachedExtra = extraBuilder.toString(); + } else { + cachedExtra = ""; + } + } + + private void updatePalette() { + if (isThemeMode()) { + int panelAlpha = bgAlpha.get(); + uiBgPrimary = HudRenderUtil.setAlpha(theme().windowBg(), panelAlpha); + uiBgSecondary = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.35f), + panelAlpha + ); + uiStroke = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.4f), + Math.min(panelAlpha, 190) + ); + uiIconColor = theme().textPrimary(); + uiValueColor = theme().textPrimary(); + uiMetaColor = theme().textMuted(); + uiExtraColor = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.18f); + return; + } + + uiBgPrimary = bg.getArgb(); + uiBgSecondary = bg2.getArgb(); + uiStroke = stroke.getArgb(); + uiIconColor = iconColor.getArgb(); + uiValueColor = valueColor.getArgb(); + uiMetaColor = metaColor.getArgb(); + uiExtraColor = extraColor.getArgb(); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean isGlassEffect() { + return EFFECT_GLASS.equals(bgEffect.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Triangulator.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Triangulator.java new file mode 100644 index 0000000..ca3dfbc --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/draggable/impl/Triangulator.java @@ -0,0 +1,1102 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.draggable.impl; + +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.*; +import dev.loki.lovisual.features.gui.hud.HudElementInfo; +import dev.loki.lovisual.features.gui.hud.HudGlobalConfig; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.features.gui.hud.HudTextEffects; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement; +import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.util.text.ClipboardUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; +import net.minecraft.world.entity.projectile.EyeOfEnder; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import java.util.LinkedHashMap; +import java.util.*; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementInfo( + id = "triangulator", + displayName = "Triangulator", + enabledByDefault = false, + order = 940 +) +public final class Triangulator extends DraggableHudElement { + + // Baked HUD layout default from user cfg. + { + defaultLayout(1302.04f, 330.69208f); + } + + + private static final Identifier HEADER_ICON = + Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/coords.png"); + + private static final float HEADER_HEIGHT = 15.5f; + private static final float BODY_Y_OFFSET = 16.5f; + private static final float BODY_INSET_Y = 6.5f; + private static final float CONTENT_START_Y = 23.0f; + private static final float ROW_STEP = 11.0f; + private static final float MIN_WIDTH = 126.0f; + private static final float PANEL_RADIUS = 4.0f; + private static final float PANEL_SOFTNESS = 1.0f; + private static final float PANEL_STROKE = 0.55f; + private static final float HEADER_DIVIDER_X = 18.0f; + private static final float HEADER_DIVIDER_Y = 5.0f; + private static final float HEADER_DIVIDER_H = 6.0f; + private static final float TITLE_ICON_X = 4.5f; + private static final float TITLE_TEXT_X = 22.0f; + private static final float CLEAR_BUTTON_SIZE = 9.0f; + private static final float CLEAR_BUTTON_RIGHT = 8.0f; + private static final float HEADER_BUTTON_GAP = 3.0f; + private static final int COPY_COORD_Y = 64; + private static final float COUNT_LABEL_OFFSET = 28.0f; + private static final float COUNT_VALUE_OFFSET = 4.0f; + private static final float SUMMARY_LINE_1_Y = 0.0f; + private static final float SUMMARY_LINE_2_Y = 11.0f; + private static final float SUMMARY_LINE_3_Y = 20.5f; + private static final float SUMMARY_HEIGHT = 30.5f; + private static final float SUMMARY_DIVIDER_Y = 34.5f; + private static final float SUMMARY_DIVIDER_INSET_X = 8.0f; + private static final float SUMMARY_DIVIDER_H = 0.55f; + private static final float ROW_DIVIDER_X = 14.5f; + private static final float ROW_DIVIDER_H = 6.5f; + private static final float ROW_TEXT_X = 19.0f; + private static final float ROW_VALUE_RIGHT = 8.0f; + private static final float ROW_CONTENT_CENTER_OFFSET = 1.95f; + private static final float ROW_MARKER_X = 4.0f; + private static final float ROW_MARKER_SIZE = 7.5f; + private static final float SEARCH_RANGE = 196.0f; + + private static final double MIN_HORIZONTAL_SPEED = 0.03; + private static final double CAPTURE_DEVIATION_LIMIT = 0.35; + private static final double CAPTURE_QUALITY_FLOOR = 0.55; + private static final double MIN_SOLVABLE_ANGLE_DEG = 0.05; + private static final long RESET_FLASH_MS = 3000L; + + private static final String COLOR_THEME = "Theme"; + private static final String COLOR_CUSTOM = "Custom"; + private static final String EFFECT_NONE = "None"; + private static final String EFFECT_BLUR = "Blur"; + + private static final List PREVIEW_THROWS = List.of( + new CachedThrow(1L, 110.0, 58.0, 0.9238795, -0.3826834, 0.91, 0.996, 0.08, 8, 0L), + new CachedThrow(2L, 188.0, -24.0, 0.7071067, -0.7071067, 0.87, 0.992, 0.12, 7, 0L), + new CachedThrow(3L, 254.0, -102.0, 0.4713967, -0.8819212, 0.82, 0.989, 0.19, 6, 0L) + ); + private static final SolveResult PREVIEW_RESULT = solveStatic(PREVIEW_THROWS, 6.0); + + private final Minecraft mc = Minecraft.getInstance(); + private final HudGlobalConfig hud = HudGlobalConfig.get(); + private final ScriptedTriangulatorHudPanel scriptedPanel = new ScriptedTriangulatorHudPanel(); + + private final NumberValue scaleValue = + new NumberValue<>("triangulator_scale", 1.68, 0.5, 5.0); + private final ModeValue colorMode = + new ModeValue("triangulator_color_mode", "Theme", COLOR_THEME, COLOR_CUSTOM); + private final ModeValue bgEffect = + new ModeValue("triangulator_bg_effect", "Blur", EFFECT_NONE, EFFECT_BLUR); + private final RGBAColorValue bg = + new RGBAColorValue("triangulator_bg", "#F7343434"); + private final RGBAColorValue bg2 = + new RGBAColorValue("triangulator_bg_secondary", "#F7161616"); + private final NumberValue bgAlpha = + new NumberValue<>("triangulator_bg_alpha", 225, 0, 255); + private final RGBColorValue stroke = + new RGBColorValue("triangulator_stroke", "#5A5A5A"); + private final RGBColorValue text = + new RGBColorValue("triangulator_text", "#FFFFFF"); + private final RGBColorValue muted = + new RGBColorValue("triangulator_muted", "#A5A5A5"); + private final NumberValue blurAlpha = + new NumberValue<>("triangulator_blur_alpha", 140, 0, 255); + private final EnumValue iconEffect = + new EnumValue<>("triangulator_icon_effect", HudTextEffects.Effect.NONE, + HudTextEffects.Effect.NONE, HudTextEffects.Effect.MIX, HudTextEffects.Effect.FLOW, + HudTextEffects.Effect.PULSE, HudTextEffects.Effect.STRIPE); + private final NumberValue iconEffectSpeed = + new NumberValue<>("triangulator_icon_effect_speed", 18, 1, 60); + private final NumberValue minObservationTicks = + new NumberValue<>("triangulator_min_observation_ticks", 6, 2, 20); + private final NumberValue stabilityThreshold = + new NumberValue<>("triangulator_stability_threshold", 0.99, 0.900, 0.999); + private final NumberValue minPairAngleDeg = + new NumberValue<>("triangulator_min_pair_angle", 6.0, 0.25, 35.0); + private final NumberValue autoResetDistance = + new NumberValue<>("triangulator_auto_reset_distance", 96.0, 16.0, 512.0); + private final NumberValue maxCachedThrows = + new NumberValue<>("triangulator_max_cached_throws", 6, 2, 12); + private final NumberValue cacheLifetimeSec = + new NumberValue<>("triangulator_cache_lifetime", 180, 15, 1800); + private final BooleanValue autoReset = + new BooleanValue("triangulator_auto_reset", true); + + private final Map activeEyes = new LinkedHashMap<>(); + private final List cachedThrows = new ArrayList<>(); + + private float displayWidth = -1.0f; + private float displayHeight = -1.0f; + private float visibilityAnim = 0.0f; + private float clearButtonX; + private float clearButtonY; + private float clearButtonSize; + private float copyButtonX; + private float copyButtonY; + private float copyButtonSize; + + private long nextCaptureSerial = 1L; + private long lastWorldTime = Long.MIN_VALUE; + private long lastCaptureMs = 0L; + private long lastResetMs = 0L; + private String lastResetReason = ""; + private SolveResult currentResult; + + private int uiHeaderLeft; + private int uiHeaderRight; + private int uiBodyLeft; + private int uiBodyRight; + private int uiOutline; + private int uiText; + private int uiMuted; + private int uiCounter; + private int uiTitleText; + private int uiDivider; + private int uiBlurTint; + private int uiSuccess; + private int uiWarn; + private int uiDanger; + + public Triangulator() { + super("triangulator", "Triangulator", false); + } + + private static SolveResult solveStatic(List throwsToSolve, double minAngleThreshold) { + if (throwsToSolve == null || throwsToSolve.isEmpty()) { + return null; + } + if (throwsToSolve.size() == 1) { + CachedThrow only = throwsToSolve.get(0); + return new SolveResult( + false, + false, + only.pointX(), + only.pointZ(), + 0.0, + 0.0, + only.quality(), + Mth.floor(only.pointX() / 16.0), + Mth.floor(only.pointZ() / 16.0) + ); + } + + double qualitySum = 0.0; + double strongestAngle = 0.0; + double pairX = 0.0; + double pairZ = 0.0; + double pairWeightSum = 0.0; + double crossThreshold = Math.sin(Math.toRadians(MIN_SOLVABLE_ANGLE_DEG)); + + for (int i = 0; i < throwsToSolve.size(); i++) { + CachedThrow throwI = throwsToSolve.get(i); + qualitySum += throwI.quality(); + + for (int j = i + 1; j < throwsToSolve.size(); j++) { + CachedThrow throwJ = throwsToSolve.get(j); + double dot = Math.abs(throwI.dirX() * throwJ.dirX() + throwI.dirZ() * throwJ.dirZ()); + double angle = Math.toDegrees(Math.acos(Mth.clamp(dot, -1.0, 1.0))); + strongestAngle = Math.max(strongestAngle, angle); + + double cross = throwI.dirX() * throwJ.dirZ() - throwI.dirZ() * throwJ.dirX(); + if (Math.abs(cross) <= crossThreshold) { + continue; + } + + double deltaX = throwJ.pointX() - throwI.pointX(); + double deltaZ = throwJ.pointZ() - throwI.pointZ(); + double t = (deltaX * throwJ.dirZ() - deltaZ * throwJ.dirX()) / cross; + double intersectionX = throwI.pointX() + throwI.dirX() * t; + double intersectionZ = throwI.pointZ() + throwI.dirZ() * t; + double pairWeight = (0.35 + throwI.quality() * 0.65) + * (0.35 + throwJ.quality() * 0.65) + * Mth.clamp(0.35 + angle / Math.max(0.35, minAngleThreshold), 0.35, 4.0); + + pairX += intersectionX * pairWeight; + pairZ += intersectionZ * pairWeight; + pairWeightSum += pairWeight; + } + } + + if (pairWeightSum <= 1.0e-8) { + return new SolveResult(false, false, 0.0, 0.0, Double.POSITIVE_INFINITY, strongestAngle, 0.0, 0, 0); + } + + double x = pairX / pairWeightSum; + double z = pairZ / pairWeightSum; + double errorSum = 0.0; + for (CachedThrow cachedThrow : throwsToSolve) { + double d = distanceToLine(x, z, cachedThrow); + errorSum += d * d; + } + double rmse = Math.sqrt(errorSum / throwsToSolve.size()); + double quality = qualitySum / throwsToSolve.size(); + double effectiveAngleThreshold = Math.max(0.35, Math.min(minAngleThreshold, 2.0)); + double angleScore = Mth.clamp( + (strongestAngle - effectiveAngleThreshold) / Math.max(0.5, 12.0 - effectiveAngleThreshold), + 0.0, + 1.0 + ); + double errorScore = Mth.clamp(1.0 - rmse / 64.0, 0.0, 1.0); + double countBonus = Mth.clamp((throwsToSolve.size() - 1.0) / 3.0, 0.0, 1.0); + double confidence = Mth.clamp( + quality * 0.45 + angleScore * 0.25 + errorScore * 0.20 + countBonus * 0.10, + 0.0, + 1.0 + ); + boolean ready = strongestAngle >= effectiveAngleThreshold && rmse <= 96.0 && confidence >= 0.55; + return new SolveResult( + true, + ready, + x, + z, + rmse, + strongestAngle, + confidence, + Mth.floor(x / 16.0), + Mth.floor(z / 16.0) + ); + } + + private static double distanceToLine(double x, double z, CachedThrow cachedThrow) { + double nx = -cachedThrow.dirZ(); + double nz = cachedThrow.dirX(); + return Math.abs((x - cachedThrow.pointX()) * nx + (z - cachedThrow.pointZ()) * nz); + } + + private static String formatPercent(double value) { + return Math.round(Mth.clamp(value, 0.0, 1.0) * 100.0) + "%"; + } + + private static String formatThrowPoint(CachedThrow cachedThrow) { + return String.format(Locale.ROOT, "%d %d", + Math.round(cachedThrow.pointX()), + Math.round(cachedThrow.pointZ())); + } + + private static boolean hit(float mx, float my, float x, float y, float w, float h) { + return mx >= x && mx <= x + w && my >= y && my <= y + h; + } + + @Override + protected void defineSettings(List defs) { + defs.add(SettingDef.number(scaleValue)); + defs.add(SettingDef.mode(colorMode)); + defs.add(SettingDef.color(bg).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.color(bg2).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(stroke).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.number(bgAlpha).visibleWhen(this::isThemeMode)); + defs.add(SettingDef.colorNoAlpha(text).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.colorNoAlpha(muted).visibleWhen(this::isCustomMode)); + defs.add(SettingDef.mode(bgEffect)); + defs.add(SettingDef.number(blurAlpha).visibleWhen(this::hasEffect)); + defs.add(SettingDef.mode(iconEffect)); + defs.add(SettingDef.number(iconEffectSpeed) + .visibleWhen(() -> iconEffect.get() != HudTextEffects.Effect.NONE)); + defs.add(SettingDef.number(minObservationTicks)); + defs.add(SettingDef.number(stabilityThreshold)); + defs.add(SettingDef.number(minPairAngleDeg)); + defs.add(SettingDef.bool(autoReset)); + defs.add(SettingDef.number(autoResetDistance).visibleWhen(autoReset::get)); + defs.add(SettingDef.number(maxCachedThrows)); + defs.add(SettingDef.number(cacheLifetimeSec)); + } + + @Override + public void applyDefaultPosition(int screenW, int screenH) { + this.x = screenW - 184.0f; + this.y = 140.0f; + } + + @Override + public boolean usesEngineRenderer() { + return true; + } + + @Override + public boolean isMouseOverInteractive(float mx, float my) { + return (clearButtonSize > 0.0f + && cachedThrows.size() > 0 + && hit(mx, my, clearButtonX, clearButtonY, clearButtonSize, clearButtonSize)) + || (copyButtonSize > 0.0f + && currentCopyResult() != null + && hit(mx, my, copyButtonX, copyButtonY, copyButtonSize, copyButtonSize)); + } + + @Override + public boolean onMouseClicked(float mx, float my, int button) { + if (button != 0) { + return false; + } + SolveResult copyResult = currentCopyResult(); + if (copyButtonSize > 0.0f + && copyResult != null + && hit(mx, my, copyButtonX, copyButtonY, copyButtonSize, copyButtonSize)) { + ClipboardUtil.copy(formatCopyCoordinates(copyResult)); + return true; + } + if (clearButtonSize > 0.0f + && cachedThrows.size() > 0 + && hit(mx, my, clearButtonX, clearButtonY, clearButtonSize, clearButtonSize)) { + clearAllState("Manual clear", true, System.currentTimeMillis()); + return true; + } + return false; + } + + private SolveResult currentCopyResult() { + SolveResult result = currentResult != null ? currentResult : solve(cachedThrows); + return result != null && result.hasSolution() ? result : null; + } + + @Override + public void onTick() { + if (!isEnabled()) { + activeEyes.clear(); + return; + } + if (mc == null || mc.player == null || mc.level == null) { + activeEyes.clear(); + currentResult = solve(cachedThrows); + return; + } + + long now = System.currentTimeMillis(); + long worldTime = mc.level.getGameTime(); + if (lastWorldTime != Long.MIN_VALUE && worldTime + 200L < lastWorldTime) { + activeEyes.clear(); + } + lastWorldTime = worldTime; + + if (cacheLifetimeSec.get() > 0 + && !cachedThrows.isEmpty() + && activeEyes.isEmpty() + && (currentResult == null || !currentResult.hasSolution()) + && lastCaptureMs > 0L + && now - lastCaptureMs > cacheLifetimeSec.get() * 1000L) { + clearAllState("Cache expired", true, now); + } + + updateTrackedEyes(now); + currentResult = solve(cachedThrows); + } + + @Override + public void renderEngine(Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + int screenW, + int screenH) { + boolean preview = DraggableHudElementRegistry.isForceVisible(); + if (!preview && (!isEnabled() || mc == null || mc.player == null || mc.level == null)) { + width = 0.0f; + height = 0.0f; + return; + } + + updatePalette(); + + List displayThrows = preview && cachedThrows.isEmpty() ? PREVIEW_THROWS : cachedThrows; + SolveResult displayResult = preview && cachedThrows.isEmpty() ? PREVIEW_RESULT : currentResult; + if (displayResult == null && !displayThrows.isEmpty()) { + displayResult = solve(displayThrows); + } + DisplayState state = buildDisplayState(displayThrows, displayResult, preview); + + visibilityAnim = HudRenderUtil.animateVisibility(visibilityAnim, true); + float widgetScale = HudRenderUtil.visibilityScale(visibilityAnim); + + TextRenderer headerTextRenderer = Fonts.renderer("Inter", FontInfo.Type.Bold, TextRenderer.get()); + TextRenderer rowTextRenderer = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, TextRenderer.get()); + if (headerTextRenderer == null) headerTextRenderer = textRenderer; + if (rowTextRenderer == null) rowTextRenderer = textRenderer; + + float baseScale = HudScale.scale(screenW, screenH) * 1.1f * scaleValue.get().floatValue(); + float fontScale = 0.98f * (hud.getFontSize() / 18.0f); + + headerTextRenderer.begin(fontScale, true, false); + float headerTextH = (float) headerTextRenderer.getHeight(false); + float titleWidth = (float) headerTextRenderer.getWidth("Triangulator", false); + headerTextRenderer.end(); + + rowTextRenderer.begin(fontScale, true, false); + float rowTextH = (float) rowTextRenderer.getHeight(false); + float maxWidth = MIN_WIDTH * baseScale; + maxWidth = Math.max(maxWidth, (float) rowTextRenderer.getWidth(state.statusText(), false) + 52.0f * baseScale); + maxWidth = Math.max(maxWidth, (float) rowTextRenderer.getWidth(state.primaryLine(), false) + 20.0f * baseScale); + rowTextRenderer.end(); + + rowTextRenderer.begin(fontScale * 0.92f, false, false); + maxWidth = Math.max(maxWidth, (float) rowTextRenderer.getWidth(state.secondaryLine(), false) + 20.0f * baseScale); + String activeCountText = Integer.toString(displayThrows.size()); + float activeLabelWidth = (float) rowTextRenderer.getWidth("Eyes:", false); + float activeCountWidth = (float) rowTextRenderer.getWidth(activeCountText, false); + rowTextRenderer.end(); + + for (int i = 0; i < displayThrows.size(); i++) { + CachedThrow cachedThrow = displayThrows.get(i); + rowTextRenderer.begin(fontScale, true, false); + float rowWidth = (float) rowTextRenderer.getWidth("Eye #" + (i + 1), false) + + (float) rowTextRenderer.getWidth(formatThrowPoint(cachedThrow), false) + + 36.0f * baseScale; + rowTextRenderer.end(); + maxWidth = Math.max(maxWidth, rowWidth); + } + + boolean showClearButton = !displayThrows.isEmpty(); + boolean showCopyButton = displayResult != null && displayResult.hasSolution(); + int headerButtons = (showClearButton ? 1 : 0) + (showCopyButton ? 1 : 0); + float headerButtonsWidth = headerButtons == 0 ? 0.0f + : ((CLEAR_BUTTON_SIZE * headerButtons) + (HEADER_BUTTON_GAP * Math.max(0, headerButtons - 1)) + 4.0f) * baseScale; + float headerWidth = (TITLE_TEXT_X * baseScale) + titleWidth + + activeLabelWidth + activeCountWidth + + headerButtonsWidth + + ((COUNT_LABEL_OFFSET + 16.0f) * baseScale); + maxWidth = Math.max(maxWidth, headerWidth); + + float rowsHeight = displayThrows.isEmpty() ? 0.0f : displayThrows.size() * (ROW_STEP * baseScale); + float dividerHeight = displayThrows.isEmpty() ? 0.0f : 5.0f * baseScale; + float targetHeight = (CONTENT_START_Y * baseScale) + (SUMMARY_HEIGHT * baseScale) + dividerHeight + rowsHeight; + + displayWidth = HudRenderUtil.animateDimension(displayWidth, maxWidth); + displayHeight = HudRenderUtil.animateDimension(displayHeight, targetHeight); + width = displayWidth; + height = displayHeight; + + float drawScale = Math.max(0.0f, widgetScale); + float drawWidth = displayWidth * drawScale; + float drawHeight = displayHeight * drawScale; + if (drawWidth <= 0.0f || drawHeight <= 0.0f) { + width = 0.0f; + height = 0.0f; + return; + } + + float drawX = x + ((displayWidth - drawWidth) * 0.5f); + float drawY = y + ((displayHeight - drawHeight) * 0.5f); + float drawBaseScale = baseScale * drawScale; + float drawFontScale = fontScale * drawScale; + float drawHeaderTextH = headerTextH * drawScale; + float drawRowTextH = rowTextH * drawScale; + + float buttonCursorRight = CLEAR_BUTTON_RIGHT * drawBaseScale; + if (showClearButton) { + clearButtonSize = CLEAR_BUTTON_SIZE * drawBaseScale; + clearButtonX = drawX + drawWidth - buttonCursorRight - clearButtonSize; + clearButtonY = drawY + (((HEADER_HEIGHT * drawBaseScale) - clearButtonSize) * 0.5f); + buttonCursorRight += clearButtonSize + (HEADER_BUTTON_GAP * drawBaseScale); + } else { + clearButtonSize = 0.0f; + } + if (showCopyButton) { + copyButtonSize = CLEAR_BUTTON_SIZE * drawBaseScale; + copyButtonX = drawX + drawWidth - buttonCursorRight - copyButtonSize; + copyButtonY = drawY + (((HEADER_HEIGHT * drawBaseScale) - copyButtonSize) * 0.5f); + } else { + copyButtonSize = 0.0f; + } + + int headerIconColor = uiCounter; + if (iconEffect.get() != HudTextEffects.Effect.NONE) { + headerIconColor = HudTextEffects.animatedColor( + headerIconColor, + iconEffect.get(), + iconEffectSpeed.get(), + (float) (Util.getMillis() / 1000.0), + 0.0f + ); + } + + List> panelRows = ScriptedTriangulatorHudPanel.rows(); + for (int i = 0; i < displayThrows.size(); i++) { + CachedThrow cachedThrow = displayThrows.get(i); + int markerColor = solveMarkerColor(cachedThrow, displayResult, i, displayThrows.size()); + panelRows.add(ScriptedTriangulatorHudPanel.row( + "eye:" + cachedThrow.serial(), + "Eye #" + (i + 1), + formatThrowPoint(cachedThrow), + uiText, + markerColor, + markerColor, + HudRenderUtil.scaleAlpha(uiMuted, 0.45f), + 1.0f + )); + } + + scriptedPanel.render( + renderer, + textRenderer, + ctx, + tickDelta, + new ScriptedTriangulatorHudPanel.Panel( + new ScriptedTriangulatorHudPanel.Palette( + uiHeaderLeft, + uiHeaderRight, + uiBodyLeft, + uiBodyRight, + uiOutline, + uiText, + uiMuted, + uiCounter, + uiTitleText, + uiDivider, + uiBlurTint, + uiSuccess, + uiWarn, + uiDanger + ), + drawX, + drawY, + drawWidth, + drawHeight, + drawScale, + drawBaseScale, + drawFontScale, + drawHeaderTextH, + drawRowTextH, + activeLabelWidth * drawScale, + activeCountWidth * drawScale, + displayThrows.size(), + hasEffect(), + Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)), + ScriptedTriangulatorHudPanel.idString(HEADER_ICON), + headerIconColor, + showClearButton, + uiDanger, + showCopyButton, + uiCounter, + state.statusText(), + state.primaryLine(), + state.secondaryLine(), + state.confidenceText(), + state.statusColor(), + panelRows + ) + ); + } + + private static String formatCopyCoordinates(SolveResult result) { + if (result == null || !result.hasSolution()) { + return ""; + } + return String.format(Locale.ROOT, "%d %d %d", Math.round(result.x()), COPY_COORD_Y, Math.round(result.z())); + } + + private DisplayState buildDisplayState(List throwsToShow, SolveResult result, boolean preview) { + long now = System.currentTimeMillis(); + if (throwsToShow.isEmpty()) { + if (!lastResetReason.isEmpty() && now - lastResetMs <= RESET_FLASH_MS) { + return new DisplayState(lastResetReason, "Cache cleared", "Waiting for a new eye", "", uiDanger); + } + if (!activeEyes.isEmpty()) { + return new DisplayState("Tracking eye", "Stabilizing trajectory", "Need one clean capture", "", uiWarn); + } + if (preview) { + return new DisplayState("Solved", formatCoordinateLine(1288.0, -412.0), + formatResultLine(1288.0, -412.0, 5.2), "88%", uiSuccess); + } + return new DisplayState("Waiting for eye", "Throw an eye of ender", "Second clean eye unlocks result", "", uiMuted); + } + + if (throwsToShow.size() == 1) { + CachedThrow first = throwsToShow.get(0); + return new DisplayState( + activeEyes.isEmpty() ? "Need second eye" : "Tracking next eye", + String.format(Locale.ROOT, "Q %s Stability %.3f", formatPercent(first.quality()), first.stability()), + "Need another angle for solve", + "", + uiWarn + ); + } + + if (result == null || !result.hasSolution()) { + return new DisplayState( + "Angles too close", + String.format(Locale.ROOT, "Best pair %.2f deg", result == null ? 0.0 : result.minPairAngleDeg()), + "Move farther, then throw again", + "", + uiWarn + ); + } + + if (!result.ready()) { + return new DisplayState( + "Low correlation", + formatCoordinateLine(result.x(), result.z()), + String.format(Locale.ROOT, "%s Pair %.2f deg", formatResultLine(result.x(), result.z(), result.rmse()), result.minPairAngleDeg()), + formatPercent(result.confidence()), + uiWarn + ); + } + + if (!result.ready()) { + return new DisplayState( + "Low correlation", + String.format(Locale.ROOT, "X %d Z %d", Math.round(result.x()), Math.round(result.z())), + String.format(Locale.ROOT, "Err %.1fb Min angle %.1fВ°", result.rmse(), result.minPairAngleDeg()), + formatPercent(result.confidence()), + uiWarn + ); + } + + return new DisplayState( + "Solved", + formatCoordinateLine(result.x(), result.z()), + formatResultLine(result.x(), result.z(), result.rmse()), + formatPercent(result.confidence()), + uiSuccess + ); + } + + private void updateTrackedEyes(long now) { + if (mc == null || mc.player == null || mc.level == null) { + activeEyes.clear(); + return; + } + if (mc.level.dimension() != Level.OVERWORLD) { + cleanupMissingEyes(Set.of(), now); + return; + } + + AABB searchBox = mc.player.getBoundingBox().inflate(SEARCH_RANGE); + List eyes = mc.level.getEntitiesOfClass( + EyeOfEnder.class, + searchBox, + entity -> entity != null && entity.isAlive() + ); + + Set seen = new HashSet<>(); + for (EyeOfEnder eye : eyes) { + seen.add(eye.getId()); + TrackedEye trackedEye = activeEyes.computeIfAbsent(eye.getId(), TrackedEye::new); + trackedEye.observe(eye, now); + + if (!trackedEye.captured()) { + TrackedCapture capture = trackedEye.tryCapture( + minObservationTicks.get(), + stabilityThreshold.get(), + CAPTURE_DEVIATION_LIMIT + ); + if (capture != null) { + trackedEye.markCaptured(); + acceptCapture(capture, now); + } + } + } + + cleanupMissingEyes(seen, now); + } + + private String formatCoordinateLine(double overworldX, double overworldZ) { + if (mc != null && mc.level != null && mc.level.dimension() == Level.NETHER) { + return String.format(Locale.ROOT, "N %d %d", Math.round(overworldX / 8.0), Math.round(overworldZ / 8.0)); + } + return String.format(Locale.ROOT, "X %d Z %d", Math.round(overworldX), Math.round(overworldZ)); + } + + private String formatResultLine(double overworldX, double overworldZ, double rmse) { + if (mc != null && mc.level != null && mc.level.dimension() == Level.NETHER) { + return String.format(Locale.ROOT, "OW %d %d Err %.1fb", + Math.round(overworldX), Math.round(overworldZ), rmse); + } + return String.format(Locale.ROOT, "Err %.1fb Chunk %d %d", + rmse, Mth.floor(overworldX / 16.0), Mth.floor(overworldZ / 16.0)); + } + + private void cleanupMissingEyes(Set seen, long now) { + List toRemove = new ArrayList<>(); + for (Map.Entry entry : activeEyes.entrySet()) { + TrackedEye trackedEye = entry.getValue(); + if (seen.contains(entry.getKey())) { + continue; + } + if (!trackedEye.captured()) { + TrackedCapture capture = trackedEye.tryCapture( + minObservationTicks.get(), + stabilityThreshold.get() - 0.01, + CAPTURE_DEVIATION_LIMIT * 1.25 + ); + if (capture != null) { + trackedEye.markCaptured(); + acceptCapture(capture, now); + } + } + if (trackedEye.captured() || now - trackedEye.lastSeenMs() > 600L) { + toRemove.add(entry.getKey()); + } + } + for (Integer id : toRemove) { + activeEyes.remove(id); + } + } + + private void acceptCapture(TrackedCapture capture, long now) { + if (capture.quality() < CAPTURE_QUALITY_FLOOR) { + return; + } + + CachedThrow cachedThrow = new CachedThrow( + nextCaptureSerial++, + capture.pointX(), + capture.pointZ(), + capture.dirX(), + capture.dirZ(), + capture.quality(), + capture.stability(), + capture.deviation(), + capture.samples(), + now + ); + + if (autoReset.get() && !cachedThrows.isEmpty()) { + SolveResult existing = currentResult != null ? currentResult : solve(cachedThrows); + if (existing != null && existing.ready()) { + double mismatch = distanceToLine(existing.x(), existing.z(), cachedThrow); + if (mismatch > autoResetDistance.get()) { + clearAllState("Different stronghold", true, now); + } + } + } + + cachedThrows.add(cachedThrow); + while (cachedThrows.size() > maxCachedThrows.get()) { + cachedThrows.remove(0); + } + lastCaptureMs = now; + lastResetReason = ""; + currentResult = solve(cachedThrows); + } + + private void clearAllState(String reason, boolean rememberReason, long now) { + activeEyes.clear(); + cachedThrows.clear(); + currentResult = null; + nextCaptureSerial = 1L; + lastCaptureMs = 0L; + if (rememberReason) { + lastResetReason = reason; + lastResetMs = now; + } else { + lastResetReason = ""; + lastResetMs = 0L; + } + } + + private SolveResult solve(List throwsToSolve) { + return solveStatic(throwsToSolve, minPairAngleDeg.get()); + } + + private int solveMarkerColor(CachedThrow cachedThrow, SolveResult result, int index, int total) { + if (result == null || !result.hasSolution()) { + return index == total - 1 ? uiWarn : uiCounter; + } + double error = distanceToLine(result.x(), result.z(), cachedThrow); + if (error <= 8.0) { + return uiSuccess; + } + if (error <= autoResetDistance.get() * 0.5) { + return uiWarn; + } + return uiDanger; + } + + private void drawWidgetBlur(float px, + float headerY, + float width, + float headerHeight, + float bodyY, + float bodyHeight, + float radius) { + if (!hasEffect() || width <= 0.0f || headerHeight <= 0.0f) { + return; + } + float blurStrength = Math.min(1.0f, (blurAlpha.get() / 255.0f) * (isThemeMode() ? 1.15f : 1.0f)); + int blurTint = uiBlurTint & 0x00FFFFFF; + float blurQuality = hud.getBlurRadius(); + + Renderer2D.COLOR.blurComposite(composite -> { + composite.roundedRectCorners( + px, headerY, width, headerHeight, + radius, 0.0f, radius, 0.0f, + blurQuality, 1.0f, blurStrength, blurTint + ); + if (bodyHeight > 0.0f) { + composite.roundedRectCorners( + px, bodyY, width, bodyHeight, + 0.0f, radius, 0.0f, radius, + blurQuality, 1.0f, blurStrength, blurTint + ); + } + }); + } + + private void updatePalette() { + if (isThemeMode()) { + int alpha = bgAlpha.get(); + 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) + ); + + uiHeaderLeft = HudRenderUtil.mixColor(header, window, 0.22f); + uiHeaderRight = HudRenderUtil.mixColor(surface, header, 0.52f); + uiBodyLeft = HudRenderUtil.mixColor(window, surface, 0.24f); + uiBodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f); + uiOutline = HudRenderUtil.setAlpha( + HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), + Math.min(255, Math.max(182, alpha + 36)) + ); + uiText = theme().textPrimary(); + uiTitleText = HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f); + uiMuted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f); + uiCounter = HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.68f); + uiBlurTint = HudRenderUtil.mixColor( + HudRenderUtil.mixColor(uiHeaderRight, uiBodyRight, 0.5f), + theme().accent(), + 0.14f + ); + } else { + uiHeaderLeft = bg.getArgb(); + uiHeaderRight = bg2.getArgb(); + uiBodyLeft = HudRenderUtil.mixColor(bg.getArgb(), bg2.getArgb(), 0.15f); + uiBodyRight = bg2.getArgb(); + uiOutline = stroke.getArgb(); + uiText = text.getArgb(); + uiTitleText = uiText; + uiMuted = muted.getArgb(); + uiCounter = HudRenderUtil.mixColor(text.getArgb(), 0xFFE1E1FF, 0.20f); + uiDivider = HudRenderUtil.scaleAlpha(uiMuted, 0.55f); + uiBlurTint = HudRenderUtil.mixColor(uiHeaderLeft, uiBodyRight, 0.5f); + } + + uiSuccess = HudRenderUtil.mixColor(uiCounter, 0xFF55FF93, 0.55f); + uiWarn = HudRenderUtil.mixColor(uiCounter, 0xFFFFC85A, 0.55f); + uiDanger = HudRenderUtil.mixColor(uiCounter, 0xFFFF6A6A, 0.62f); + } + + private boolean isThemeMode() { + return COLOR_THEME.equals(colorMode.get()); + } + + private boolean isCustomMode() { + return COLOR_CUSTOM.equals(colorMode.get()); + } + + private boolean hasEffect() { + return !EFFECT_NONE.equals(bgEffect.get()); + } + + public XaeroSnapshot getXaeroSnapshot() { + SolveResult result = currentResult != null ? currentResult : solve(cachedThrows); + if (result == null || !result.hasSolution()) { + return null; + } + return new XaeroSnapshot(result.x(), result.z(), result.ready(), result.confidence(), cachedThrows.size()); + } + + public record XaeroSnapshot(double overworldX, + double overworldZ, + boolean ready, + double confidence, + int eyeCount) { + } + + private record Observation(double x, double z, double dirX, double dirZ, double speed) { + } + + private record TrackedCapture(double pointX, + double pointZ, + double dirX, + double dirZ, + double quality, + double stability, + double deviation, + int samples) { + } + + private record CachedThrow(long serial, + double pointX, + double pointZ, + double dirX, + double dirZ, + double quality, + double stability, + double deviation, + int samples, + long capturedAtMs) { + } + + private record SolveResult(boolean hasSolution, + boolean ready, + double x, + double z, + double rmse, + double minPairAngleDeg, + double confidence, + int chunkX, + int chunkZ) { + } + + private record DisplayState(String statusText, + String primaryLine, + String secondaryLine, + String confidenceText, + int statusColor) { + } + + private static final class TrackedEye { + private final List observations = new ArrayList<>(); + private long lastSeenMs; + private boolean captured; + + private TrackedEye(int entityId) { + } + + private void observe(EyeOfEnder eye, long now) { + if (eye == null) { + return; + } + Vec3 velocity = eye.getDeltaMovement(); + double speed = Math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); + if (speed < MIN_HORIZONTAL_SPEED) { + lastSeenMs = now; + return; + } + observations.add(new Observation(eye.getX(), eye.getZ(), velocity.x / speed, velocity.z / speed, speed)); + if (observations.size() > 14) { + observations.remove(0); + } + lastSeenMs = now; + } + + private TrackedCapture tryCapture(int minTicks, double stabilityThreshold, double deviationLimit) { + if (captured || observations.size() < Math.max(2, minTicks)) { + return null; + } + + double dirX = 0.0; + double dirZ = 0.0; + double avgSpeed = 0.0; + for (Observation observation : observations) { + dirX += observation.dirX(); + dirZ += observation.dirZ(); + avgSpeed += observation.speed(); + } + avgSpeed /= observations.size(); + + double dirLen = Math.sqrt(dirX * dirX + dirZ * dirZ); + if (dirLen <= 1.0e-6) { + return null; + } + dirX /= dirLen; + dirZ /= dirLen; + + double centroidX = 0.0; + double centroidZ = 0.0; + double avgCos = 0.0; + double minCos = 1.0; + for (Observation observation : observations) { + centroidX += observation.x(); + centroidZ += observation.z(); + double dot = Mth.clamp(observation.dirX() * dirX + observation.dirZ() * dirZ, -1.0, 1.0); + avgCos += dot; + minCos = Math.min(minCos, dot); + } + centroidX /= observations.size(); + centroidZ /= observations.size(); + avgCos /= observations.size(); + + double normalX = -dirZ; + double normalZ = dirX; + double deviation = 0.0; + for (Observation observation : observations) { + deviation += Math.abs((observation.x() - centroidX) * normalX + (observation.z() - centroidZ) * normalZ); + } + deviation /= observations.size(); + + if (avgCos < stabilityThreshold || deviation > deviationLimit) { + return null; + } + + double stabilityScore = Mth.clamp( + (avgCos - stabilityThreshold) / Math.max(1.0e-5, 1.0 - stabilityThreshold), + 0.0, + 1.0 + ); + double minCosScore = Mth.clamp( + (minCos - (stabilityThreshold - 0.01)) / Math.max(1.0e-5, 1.01 - stabilityThreshold), + 0.0, + 1.0 + ); + double deviationScore = Math.exp(-deviation / 0.15); + double speedScore = Mth.clamp(avgSpeed / 0.35, 0.0, 1.0); + double quality = Mth.clamp( + stabilityScore * 0.40 + minCosScore * 0.25 + deviationScore * 0.20 + speedScore * 0.15, + 0.0, + 1.0 + ); + + return new TrackedCapture( + centroidX, + centroidZ, + dirX, + dirZ, + quality, + avgCos, + deviation, + observations.size() + ); + } + + private boolean captured() { + return captured; + } + + private void markCaptured() { + captured = true; + } + + private long lastSeenMs() { + return lastSeenMs; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementBootstrap.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementBootstrap.java new file mode 100644 index 0000000..6ac20ac --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementBootstrap.java @@ -0,0 +1,23 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.nondraggable; + +import dev.loki.lovisual.features.gui.hud.HudElementAutoLoader; + +public enum StaticHudElementBootstrap { + ; + + private static boolean initialized = false; + + public static void init() { + if (initialized) return; + initialized = true; + + HudElementAutoLoader.loadStatic("dev.loki.lovisual.features.gui.hud.nondraggable.impl"); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementRegistry.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementRegistry.java new file mode 100644 index 0000000..8c0ba71 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/StaticHudElementRegistry.java @@ -0,0 +1,199 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.nondraggable; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.HudRenderSpace; +import dev.loki.lovisual.features.module.HudPhase; +import dev.loki.lovisual.render.engine.math.HudScale; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.runtime.RuntimeGate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Registry for non-draggable (vanilla/static) HUD elements. + */ +public enum StaticHudElementRegistry { + ; + + private static final List ELEMENTS = new ArrayList<>(); + + public static void register(AbstractHudElement element) { + if (element == null) return; + if (ELEMENTS.contains(element)) return; + element.postInit(); + ELEMENTS.add(element); + } + + public static void clearRuntimeReferences() { + ELEMENTS.clear(); + } + + public static List getAll() { + return Collections.unmodifiableList(ELEMENTS); + } + + public static AbstractHudElement getById(String id) { + if (id == null) return null; + for (AbstractHudElement e : ELEMENTS) { + if (id.equals(e.getId())) return e; + } + return null; + } + + public static T widget(Class type) { + if (type == null) return null; + for (AbstractHudElement e : ELEMENTS) { + if (type.isInstance(e)) { + return type.cast(e); + } + } + return null; + } + + public static void tickAll() { + if (!RuntimeGate.canRunHud()) return; + for (AbstractHudElement e : ELEMENTS) { + e.onTick(); + } + } + + public static void renderAllEngine(HudPhase phase, + HudRenderSpace space, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + renderAllEngineInternal(phase, space, renderer, textRenderer, ctx, tickDelta, false); + } + + public static void renderAllEngineForeground(HudPhase phase, + HudRenderSpace space, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta) { + renderAllEngineInternal(phase, space, renderer, textRenderer, ctx, tickDelta, true); + } + + public static boolean hasEngineWork(HudPhase phase, HudRenderSpace space, boolean foreground) { + if (!RuntimeGate.canRunHud()) return false; + for (AbstractHudElement element : ELEMENTS) { + if (element == null || !element.isEnabled()) continue; + if (!element.usesEngineRenderer()) continue; + if (element.getHudPhase() != phase) continue; + if (element.getRenderSpace() != space) continue; + return true; + } + return false; + } + + public static boolean handleMouseButton(int button, boolean pressed) { + if (!RuntimeGate.canRunHud()) return false; + if (!pressed) return false; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return false; + + List interactive = new ArrayList<>(); + for (AbstractHudElement element : ELEMENTS) { + if (element == null || !element.isEnabled()) continue; + if (!element.usesEngineRenderer()) continue; + interactive.add(element); + } + interactive.sort(Comparator.comparingInt(AbstractHudElement::getRenderOrder).reversed()); + + int fbw = mc.getWindow().getWidth(); + int fbh = mc.getWindow().getHeight(); + float logicalScale = HudScale.scale(fbw, fbh); + float scaledFactor = (float) mc.getWindow().getGuiScale(); + float rawX = (float) mc.mouseHandler.xpos(); + float rawY = (float) mc.mouseHandler.ypos(); + + for (AbstractHudElement element : interactive) { + float mx = switch (element.getRenderSpace()) { + case UNSCALED -> rawX; + case UNSCALED_LOGICAL -> HudScale.toVirtual(rawX, logicalScale); + case SCALED -> scaledFactor > 0f ? rawX / scaledFactor : rawX; + }; + float my = switch (element.getRenderSpace()) { + case UNSCALED -> rawY; + case UNSCALED_LOGICAL -> HudScale.toVirtual(rawY, logicalScale); + case SCALED -> scaledFactor > 0f ? rawY / scaledFactor : rawY; + }; + if (!element.isMouseOverInteractive(mx, my)) continue; + if (element.onMouseClicked(mx, my, button)) { + return true; + } + } + return false; + } + + private static void renderAllEngineInternal(HudPhase phase, + HudRenderSpace space, + Renderer2D renderer, + TextRenderer textRenderer, + GuiGraphicsExtractor ctx, + float tickDelta, + boolean foreground) { + if (!RuntimeGate.canRunHud()) return; + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.getWindow() == null) return; + + int[] dims = screenSizeFor(space, mc); + int screenW = dims[0]; + int screenH = dims[1]; + + List renderList = new ArrayList<>(); + for (AbstractHudElement element : ELEMENTS) { + if (element == null || !element.isEnabled()) continue; + if (!element.usesEngineRenderer()) continue; + if (element.getHudPhase() != phase) continue; + if (element.getRenderSpace() != space) continue; + renderList.add(element); + } + renderList.sort(Comparator.comparingInt(AbstractHudElement::getRenderOrder)); + + for (AbstractHudElement element : renderList) { + if (foreground) { + element.renderEngineForeground(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } else { + element.renderEngine(renderer, textRenderer, ctx, tickDelta, screenW, screenH); + } + } + } + + private static int[] screenSizeFor(HudRenderSpace space, Minecraft mc) { + return switch (space) { + case UNSCALED -> new int[]{ + Math.max(1, mc.getWindow().getWidth()), + Math.max(1, mc.getWindow().getHeight()) + }; + case SCALED -> new int[]{ + Math.max(1, mc.getWindow().getGuiScaledWidth()), + Math.max(1, mc.getWindow().getGuiScaledHeight()) + }; + case UNSCALED_LOGICAL -> new int[]{ + Math.max(1, Math.round(HudScale.virtualWidth( + mc.getWindow().getWidth(), + mc.getWindow().getHeight() + ))), + Math.max(1, Math.round(HudScale.virtualHeight( + mc.getWindow().getWidth(), + mc.getWindow().getHeight() + ))) + }; + }; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterButtons.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterButtons.java new file mode 100644 index 0000000..9b245c5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterButtons.java @@ -0,0 +1,1034 @@ +/* + * This file is part of the Combatant Client distribution. + * Copyright (c) 2026 pivosos2007. + * + * Licensed under the GNU General Public License v3.0. + */ + +package dev.loki.lovisual.features.gui.hud.nondraggable.impl; + +import dev.loki.lovisual.util.screen.ClientScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.*; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.tooltip.DefaultTooltipPositioner; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.data.AtlasIds; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.Util; +import dev.loki.lovisual.config.SettingDef; +import dev.loki.lovisual.config.values.BooleanValue; +import dev.loki.lovisual.config.values.EnumValue; +import dev.loki.lovisual.config.values.NumberValue; +import dev.loki.lovisual.config.values.RGBAColorValue; +import dev.loki.lovisual.features.gui.hud.AbstractHudElement; +import dev.loki.lovisual.features.gui.hud.HudElementRegister; +import dev.loki.lovisual.features.gui.hud.HudRenderUtil; +import dev.loki.lovisual.mixininterface.IRoundedHitbox; +import dev.loki.lovisual.mixins.accessors.ClickableWidgetAccessor; +import dev.loki.lovisual.mixins.accessors.TextIconButtonWidgetAccessor; +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.core.ViewportContext; +import dev.loki.lovisual.render.engine.renderer.RenderWarpStack; +import dev.loki.lovisual.render.engine.renderer.Renderer2D; +import dev.loki.lovisual.render.engine.text.FontInfo; +import dev.loki.lovisual.render.engine.text.Fonts; +import dev.loki.lovisual.render.engine.text.TextRenderer; +import dev.loki.lovisual.render.helpers.ScissorFunction; +import dev.loki.lovisual.runtime.RuntimeGate; +import dev.loki.lovisual.util.text.LegacyTextUtil; +import dev.loki.lovisual.util.text.TextRenderUtil; + +import java.util.*; + +import static dev.loki.lovisual.features.theme.Theme.theme; + +@HudElementRegister(order = 10) +public final class BetterButtons extends AbstractHudElement { + + public static final BetterButtons INSTANCE = new BetterButtons(); + private static final float HOVER_SPEED_IN = 20.0f; + private static final float HOVER_SPEED_OUT = 16.0f; + private static final float BUTTON_RADIUS = 6.0f; + private static final float BUTTON_SOFTNESS = 1.2f; + private static final float TEXT_SCALE_BASE = 0.42f; + private static final float TEXT_PADDING_X = 6.0f; + private static final float TEXT_Y_OFFSET = 0.0f; + private static final float PARALLAX_MAX_ANGLE = 7.0f; + private static final float PARALLAX_DEPTH = 3.5f; + private static final float PARALLAX_PERSPECTIVE = 1.0f; + private static final float PARALLAX_SCALE_BOOST = 0.018f; + private static final float PARALLAX_CONTENT_SHIFT = 1.35f; + private static final Map HOVER = new WeakHashMap<>(); + private static final Map LAST_TICK = new WeakHashMap<>(); + private static final Map MOUSE_X = new WeakHashMap<>(); + private static final Map MOUSE_Y = new WeakHashMap<>(); + private static final List QUEUE = new ArrayList<>(); + private static final Deque SCISSOR_STACK = new ArrayDeque<>(); + private static GuiGraphicsExtractor LAST_CTX; + private static Screen LAST_SCREEN; + private final EnumValue