feat: add KineticLyrics 3D lyrics rendering system

This commit is contained in:
loki5512344 2026-09-03 19:52:03 +02:00
parent 4a529466c7
commit 53ff8f8f8e
97 changed files with 6238 additions and 1220 deletions

View file

@ -0,0 +1,28 @@
{
"enabled": true,
"layout": {
"pos": [
16.0,
212.0
],
"space": "logical",
"parent": "keybinds",
"side": "RIGHT",
"pox": 0.0,
"poy": 0.0
},
"admins_scale": 1.0,
"admins_layout": "Split Header",
"admins_color_mode": "Theme",
"admins_bg": "#EB111318",
"admins_bg_secondary": "#F7161616",
"admins_stroke": "#5A5A5A",
"admins_bg_alpha": 131.0,
"admins_text": "#FFFFFF",
"admins_muted": "#A5A5A5",
"admins_bg_effect": "Blur",
"admins_blur_alpha": 255.0,
"admins_header_icon_pulse": true,
"admins_header_icon_pulse_speed": 18.0,
"admins_header_icon_pulse_intensity": 100.0
}

View file

@ -0,0 +1,17 @@
{
"enabled": false,
"layout": {
"pos": [
1616.315,
1003.6977
],
"space": "logical"
},
"armor_scale": 3.68,
"armor_layout": "Horizontal",
"armor_reverse": false,
"armor_state_mode": "Text",
"armor_state_bar_threshold": 70.0,
"armor_state_threshold": 70.0,
"armor_state_color_threshold": 70.0
}

View file

@ -0,0 +1,27 @@
{
"enabled": true,
"layout": {
"pos": [
16.0,
485.12
],
"space": "logical"
},
"width_ratio": 0.54,
"font_scale": 1.36,
"lines": 12.0,
"fade_seconds": 14.98,
"liquid_glass_alpha": 230.0,
"hide_vanilla": true,
"history_enabled": true,
"history_limit": 32000.0,
"timestamps_enabled": true,
"timestamp_color": "#C6D2CD",
"timestamp_toggles": {
"with_seconds": false,
"with_brackets": true,
"active_only": false,
"hover_unix": false,
"hover_date": true
}
}

View file

@ -0,0 +1,18 @@
{
"enabled": true,
"fps_scale": 2.37,
"fps_color_mode": "Theme",
"fps_icon_color": "#FFFFFF",
"fps_value_color": "#FFFFFF",
"fps_meta_color": "#9B9B9B",
"fps_bg_effect": "Blur",
"fps_bg": "#F7343434",
"fps_bg_secondary": "#F7161616",
"fps_stroke": "#5A5A5A",
"fps_bg_alpha": 133.0,
"fps_blur_alpha": 255.0,
"fps_label_effect": "Flow",
"fps_label_effect_speed": 18.0,
"fps_digit_animation": false,
"fps_digit_poll_ms": 495.0
}

View file

@ -0,0 +1,16 @@
{
"enabled": false,
"game_time_weather": true,
"game_time_scale": 2.37,
"game_time_color_mode": "Theme",
"game_time_icon_color": "#FFFFFF",
"game_time_value_color": "#FFFFFF",
"game_time_bg_effect": "None",
"game_time_bg": "#EB111318",
"game_time_bg_secondary": "#F7161616",
"game_time_stroke": "#5A5A5A",
"game_time_bg_alpha": 235.0,
"game_time_blur_alpha": 140.0,
"game_time_icon_effect": "Mix",
"game_time_icon_effect_speed": 18.0
}

View file

@ -0,0 +1,9 @@
{
"enabled": true,
"duration_ms": 2000.0,
"fade_ms": 250.0,
"scale": 1.35,
"sound": true,
"sound_mode": "2",
"sound_volume": 0.47
}

View file

@ -0,0 +1,25 @@
{
"enabled": true,
"layout": {
"pos": [
1255.352,
658.36005
],
"space": "logical"
},
"toggle_bind": "I",
"inventory_scale": 1.32,
"inventory_layout": "Unified Divider",
"inventory_color_mode": "Theme",
"inventory_bg": "#F7343434",
"inventory_bg_secondary": "#F7161616",
"inventory_stroke": "#5A5A5A",
"inventory_bg_alpha": 194.0,
"inventory_text": "#FFFFFF",
"inventory_muted": "#A5A5A5",
"inventory_bg_effect": "Blur",
"inventory_blur_alpha": 255.0,
"inventory_header_icon_pulse": true,
"inventory_header_icon_pulse_speed": 15.0,
"inventory_header_icon_pulse_intensity": 100.0
}

View file

@ -0,0 +1,22 @@
{
"enabled": true,
"layout": {
"pos": [
-16.0,
48.0
],
"space": "logical",
"ax": "CENTER",
"ay": "CENTER"
},
"itemizer_scale": 1.0,
"itemizer_display_mode": "icons",
"itemizer_direction": "horizontal",
"itemizer_duration": 1.15,
"itemizer_max_entries": 3.0,
"itemizer_auto_eat": true,
"itemizer_auto_totem": true,
"itemizer_elytra_swap": true,
"itemizer_blur": true,
"itemizer_bg_alpha": 168.0
}

View file

@ -0,0 +1,33 @@
{
"enabled": true,
"layout": {
"pos": [
456.6228,
248.0
],
"space": "logical",
"parent": "potions",
"side": "RIGHT",
"pox": 0.0,
"poy": 0.0
},
"keybinds_scale": 1.0,
"keybinds_layout": "Unified Divider",
"keybinds_color_mode": "Theme",
"keybinds_bg": "#F7343434",
"keybinds_bg_secondary": "#F7161616",
"keybinds_stroke": "#5A5A5A",
"keybinds_bg_alpha": 225.0,
"keybinds_text": "#FFFFFF",
"keybinds_muted": "#A5A5A5",
"keybinds_bg_effect": "None",
"keybinds_blur_alpha": 140.0,
"keybinds_local_actions": true,
"keybinds_local_action_modes": {
"Press": true,
"Hold": true
},
"keybinds_header_icon_pulse": true,
"keybinds_header_icon_pulse_speed": 18.0,
"keybinds_header_icon_pulse_intensity": 100.0
}

View file

@ -0,0 +1,19 @@
{
"enabled": false,
"layout": {
"pos": [
12.0,
82.30222
],
"space": "logical"
},
"media_player_scale": 1.51,
"media_player_sync_theme": true,
"media_player_bg": "#60000000",
"media_player_bg_alpha": 124.0,
"media_player_accent": "#FF2F2F",
"media_player_text": "#FFFFFF",
"media_player_bg_effect": "None",
"media_player_blur_alpha": 255.0,
"media_player_gradient": true
}

View file

@ -0,0 +1,17 @@
{
"enabled": true,
"memory_scale": 2.37,
"memory_color_mode": "Theme",
"memory_icon_color": "#FFFFFF",
"memory_value_color": "#FFFFFF",
"memory_meta_color": "#9B9B9B",
"memory_extra_color": "#BEBEBE",
"memory_bg_effect": "Blur",
"memory_bg": "#F7343434",
"memory_bg_secondary": "#F7161616",
"memory_stroke": "#5A5A5A",
"memory_bg_alpha": 135.0,
"memory_blur_alpha": 255.0,
"memory_label_effect": "None",
"memory_label_effect_speed": 18.0
}

View file

@ -0,0 +1,16 @@
{
"enabled": false,
"module_list_scale": 3.67,
"module_list_color_mode": "Custom",
"module_list_bg_effect": "Blur",
"module_list_bg_alpha": 226.0,
"module_list_blur_alpha": 255.0,
"module_list_bg": "#FF444242",
"module_list_bg_secondary": "#22000000",
"module_list_text": "RNB#E87171",
"module_list_text_secondary": "#93118C",
"module_list_text_gradient_mode": "rainbow",
"module_list_text_gradient_speed": 18.0,
"module_list_text_effect": "None",
"module_list_text_effect_speed": 30.0
}

View file

@ -0,0 +1,16 @@
{
"enabled": false,
"ping_scale": 2.58,
"ping_color_mode": "Theme",
"ping_icon_color": "#FFFFFF",
"ping_value_color": "#FFFFFF",
"ping_meta_color": "#9B9B9B",
"ping_bg_effect": "None",
"ping_bg": "#F7343434",
"ping_bg_secondary": "#F7161616",
"ping_stroke": "#5A5A5A",
"ping_bg_alpha": 225.0,
"ping_blur_alpha": 140.0,
"ping_label_effect": "None",
"ping_label_effect_speed": 18.0
}

View file

@ -0,0 +1,24 @@
{
"enabled": true,
"layout": {
"pos": [
31.729004,
182.0
],
"space": "logical"
},
"potions_scale": 1.05,
"potions_layout": "Unified Divider",
"potions_color_mode": "Theme",
"potions_bg": "#EB784F4F",
"potions_bg_secondary": "#F7161616",
"potions_stroke": "#5A5A5A",
"potions_bg_alpha": 200.0,
"potions_text": "#FFFFFF",
"potions_muted": "#A5A5A5",
"potions_bg_effect": "Blur",
"potions_blur_alpha": 159.0,
"potions_header_icon_pulse": true,
"potions_header_icon_pulse_speed": 18.0,
"potions_header_icon_pulse_intensity": 100.0
}

View file

@ -0,0 +1,24 @@
{
"enabled": false,
"layout": {
"pos": [
1666.0,
17.915222
],
"space": "logical"
},
"radar_mode": "Rect",
"radar_color_mode": "Sync",
"radar_size": 217.27,
"radar_bg_color": "#CC101010",
"radar_bg_alpha": 109.0,
"radar_text_color": "#FFFFFF",
"radar_field_1": "Name",
"radar_field_2": "Hp",
"radar_field_3": "Ping",
"radar_field_4": "None",
"radar_field_5": "None",
"radar_blur": true,
"radar_blur_alpha": 255.0,
"radar_gradient": false
}

View file

@ -0,0 +1,31 @@
{
"enabled": false,
"layout": {
"pos": [
1721.69,
158.0
],
"space": "logical"
},
"toggle_bind": "Э",
"scoreboard_scale": 1.93,
"scoreboard_color_mode": "Theme",
"scoreboard_bg_effect": "Blur",
"scoreboard_bg_alpha": 182.0,
"scoreboard_blur_alpha": 255.0,
"scoreboard_bg": "#820A0A0A",
"scoreboard_bg_secondary": "#B8141414",
"scoreboard_stroke": "#4A4A4A",
"scoreboard_title_color": "#FFCC00",
"scoreboard_label_color": "#FFFFFF",
"scoreboard_value_color": "#FF4A4A",
"scoreboard_footer_color": "#FFD44A",
"scoreboard_text_effect": "Stripe",
"scoreboard_text_effect_speed": 18.0,
"scoreboard_text_effect_sections": {
"header": true,
"labels": false,
"values": false,
"footer": false
}
}

View file

@ -0,0 +1,18 @@
{
"enabled": true,
"speed_bps_bps": true,
"speed_bps_average": false,
"speed_bps_scale": 2.37,
"speed_bps_color_mode": "Theme",
"speed_bps_icon_color": "#FFFFFF",
"speed_bps_value_color": "#FFFFFF",
"speed_bps_meta_color": "#9B9B9B",
"speed_bps_bg_effect": "Blur",
"speed_bps_bg": "#F7343434",
"speed_bps_bg_secondary": "#F7161616",
"speed_bps_stroke": "#5A5A5A",
"speed_bps_bg_alpha": 135.0,
"speed_bps_blur_alpha": 255.0,
"speed_bps_label_effect": "Mix",
"speed_bps_label_effect_speed": 60.0
}

View file

@ -0,0 +1,16 @@
{
"enabled": false,
"system_time_seconds": false,
"system_time_scale": 2.37,
"system_time_color_mode": "Theme",
"system_time_icon_color": "#FFFFFF",
"system_time_value_color": "#FFFFFF",
"system_time_bg_effect": "Blur",
"system_time_bg": "#EB111318",
"system_time_bg_secondary": "#F7161616",
"system_time_stroke": "#5A5A5A",
"system_time_bg_alpha": 235.0,
"system_time_blur_alpha": 140.0,
"system_time_icon_effect": "None",
"system_time_icon_effect_speed": 18.0
}

View file

@ -0,0 +1,41 @@
{
"enabled": true,
"layout": {
"pos": [
7.6799927,
721.12
],
"space": "logical",
"ax": "CENTER"
},
"target_scale": 3.28,
"target_hold_ms": 1000.0,
"totem_pop_counter": true,
"totem_pop_reset_seconds": 300.0,
"target_totem_pop_text_color": "RNB#FFFFFF",
"target_marquee": true,
"target_players_only": false,
"target_effects_mode": "Off",
"target_effects_self": true,
"target_equipment_mode": "Off",
"target_hp_mode": "Arc",
"target_color_mode": "Theme",
"target_bg": "#F7343434",
"target_bg_secondary": "#F7161616",
"target_stroke": "#5A5A5A",
"target_bg_alpha": 162.0,
"target_text": "#FFFFFF",
"target_text_secondary": "#D7D7D7",
"target_bar_bg": "#1E1E1E",
"target_health_start": "#F3F3F3",
"target_health_end": "#9E9E9E",
"target_trail": "#373737",
"target_absorption_start": "#FFC300",
"target_absorption_end": "#FFD96B",
"target_hit_outer": "#FF5A5A",
"target_hit_inner": "#FF7373",
"target_bg_effect": "Glass",
"target_blur_alpha": 255.0,
"target_hit_anim": true,
"target_hit_pulse": 1.0
}

View file

@ -0,0 +1,21 @@
{
"enabled": false,
"layout": {
"pos": [
1242.2065,
889.5111
],
"space": "logical"
},
"scale": 1.0,
"always_show": true,
"sync_theme": true,
"blur": false,
"blur_alpha": 140.0,
"blur_bg_alpha": 128.0,
"gradient": true,
"bg": "#B012151B",
"bar_start": "#5CC8E7",
"bar_end": "#55FFFF",
"text_color": "#FFFFFF"
}

View file

@ -0,0 +1,18 @@
{
"enabled": false,
"tps_extra": false,
"tps_scale": 2.37,
"tps_color_mode": "Theme",
"tps_icon_color": "#FFFFFF",
"tps_value_color": "#FFFFFF",
"tps_meta_color": "#9B9B9B",
"tps_extra_color": "#BEBEBE",
"tps_bg_effect": "Blur",
"tps_bg": "#F7343434",
"tps_bg_secondary": "#F7161616",
"tps_stroke": "#5A5A5A",
"tps_bg_alpha": 133.0,
"tps_blur_alpha": 255.0,
"tps_label_effect": "None",
"tps_label_effect_speed": 18.0
}

View file

@ -0,0 +1,29 @@
{
"enabled": false,
"layout": {
"pos": [
1302.04,
330.69208
],
"space": "logical"
},
"triangulator_scale": 1.68,
"triangulator_color_mode": "Theme",
"triangulator_bg": "#F7343434",
"triangulator_bg_secondary": "#F7161616",
"triangulator_stroke": "#5A5A5A",
"triangulator_bg_alpha": 225.0,
"triangulator_text": "#FFFFFF",
"triangulator_muted": "#A5A5A5",
"triangulator_bg_effect": "Blur",
"triangulator_blur_alpha": 140.0,
"triangulator_icon_effect": "None",
"triangulator_icon_effect_speed": 18.0,
"triangulator_min_observation_ticks": 6.0,
"triangulator_stability_threshold": 0.99,
"triangulator_min_pair_angle": 6.0,
"triangulator_auto_reset": true,
"triangulator_auto_reset_distance": 96.0,
"triangulator_max_cached_throws": 6.0,
"triangulator_cache_lifetime": 180.0
}

View file

@ -0,0 +1,18 @@
{
"enabled": true,
"xyz_nether": "On",
"xyz_scale": 2.37,
"xyz_color_mode": "Theme",
"xyz_icon_color": "#FFFFFF",
"xyz_label_color": "#9B9B9B",
"xyz_value_color": "#FFFFFF",
"xyz_extra_color": "#BEBEBE",
"xyz_bg_effect": "Blur",
"xyz_bg": "#F7343434",
"xyz_bg_secondary": "#F7161616",
"xyz_stroke": "#5A5A5A",
"xyz_bg_alpha": 184.0,
"xyz_blur_alpha": 255.0,
"xyz_label_effect": "Flow",
"xyz_label_effect_speed": 18.0
}

View file

@ -0,0 +1,11 @@
{
"enabled": true,
"dynamic_island_sync_theme": true,
"dynamic_island_theme_alpha": 200.0,
"dynamic_island_blur": false,
"dynamic_island_blur_alpha": 120.0,
"dynamic_island_bg": "#E614171D",
"dynamic_island_accent": "#6E8DFF",
"dynamic_island_text": "#F5F7FA",
"dynamic_island_muted": "#A9B1BC"
}

View file

@ -0,0 +1,7 @@
{
"enabled": true,
"swap_scale": 0.24,
"swap_offset_y": 66.6,
"swap_text_effect": "Mix",
"swap_text_effect_speed": 18.0
}

View file

@ -0,0 +1,10 @@
{
"enabled": true,
"tab_list_max_width": 1680.0,
"tab_list_sync_theme": true,
"tab_list_theme_alpha": 225.0,
"tab_list_bg": "#E50B1018",
"tab_list_accent": "#6E8DFF",
"tab_list_text": "#F4F7FB",
"tab_list_muted": "#9AA5B5"
}

View file

@ -0,0 +1,27 @@
{
"enabled": true,
"xp_bar": true,
"xp_bar_show_level": true,
"bar_mode": "level",
"bar_color_mode": "Theme",
"custom_bg_color": "#88151515",
"custom_fill_color": "#CC4CB2FF",
"custom_fill_gradient_color": "#CC76F0FF",
"custom_text_color": "#FFA0E5FF",
"locator_labels": true,
"locator_label_mode": "center",
"locator_label_angle": 6.0,
"locator_label_distance": true,
"locator_search_enabled": true,
"locator_search_list": [
"imhorny"
],
"locator_dimmed_color": "#777777",
"locator_search_color": "#FFAA00",
"locator_dimmed_scale_only": true,
"locator_map_filter_enabled": false,
"locator_map_server": "",
"locator_map_url": "example",
"locator_target_toggle": "NONE",
"locator_bar_toggle": "U"
}

View file

@ -0,0 +1,25 @@
{
"enabled": true,
"style": "Default",
"gradient_base_color": "#FF2F2929",
"gradient_color": "#FF902F2F",
"gradient_angle": 74.25,
"gradient_intensity": 0.6,
"custom_bg": "#FF1B1B1B",
"custom_bg_hover": "#FF2A2A2A",
"custom_bg_disabled": "#FF141414",
"custom_stroke": "#FF9B5DB8",
"custom_stroke_hover": "#FFA710B2",
"custom_stroke_disabled": "#FF2A2A2A",
"custom_text": "#FFECECEC",
"custom_text_disabled": "#FF7A7A7A",
"custom_radius": 6.0,
"custom_softness": 1.2,
"custom_stroke_thickness": 0.7,
"custom_gradient": true,
"custom_gradient_color": "#73521BA7",
"custom_gradient_angle": 90.0,
"custom_gradient_intensity": 0.5,
"custom_text_scale": 1.0,
"custom_text_pad_x": 6.0
}

View file

@ -0,0 +1,8 @@
{
"enabled": true,
"health_alpha": 255.0,
"health_bar_style": "TargetHud",
"custom_bar_radius": 5.0,
"custom_text_scale": 0.24,
"custom_text_padding": 3.0
}

View file

@ -0,0 +1,14 @@
{
"enabled": true,
"offhand_mode": "merged",
"hotbar_color_mode": "Custom",
"bg_color": "#7A191414",
"hotbar_bg_alpha": 167.0,
"custom_select_color": false,
"select_color": "#2F6277",
"custom_divider_color": false,
"divider_color": "#C13434",
"accent_alpha": 255.0,
"bg_effect": "Glass",
"blur_alpha": 255.0
}

View file

@ -0,0 +1,22 @@
{
"enabled": true,
"item_tooltip": true,
"shulker_preview": true,
"item_info_colorize": true,
"tooltip_font": "Iosevka",
"tooltip_text_size": 1.0,
"top_enchant_ignore": [
"minecraft:binding_curse",
"minecraft:vanishing_curse",
"minecraft:mending",
"minecraft:infinity",
"minecraft:silk_touch",
"minecraft:aqua_affinity",
"minecraft:flame",
"minecraft:channeling",
"minecraft:multishot",
"minecraft:unbreaking"
],
"shulker_slot_size": 18.0,
"shulker_preview_hold": "LEFT_SHIFT"
}

View file

@ -0,0 +1,30 @@
{
'debug': 'off',
'forcePvp': false,
'menuBg': 'off',
'menuClockShowSeconds': false,
'menuBgUseTheme': false,
'msaa3d': 'off',
'clickGuiModulesHints': true,
'clickGuiHudEditorHints': true,
'inventorySwapPolicy': 'none',
'inventorySwapScope': 'full',
'inventorySwapVisibility': 'silent',
'inventorySwapRestore': true,
'inventorySwapPreferHotbar': true,
'inventorySwapLegitWaitTicks': 2.0,
'inventorySwapStrictInventoryWaitTicks': 1.0,
'inventorySwapStrictMovementLockTicks': 2.0,
'disableNarrator': true,
'backdoorProtection': true,
'backdoorTranslationFilter': true,
'backdoorKeybindFilter': true,
'backdoorLocalHttpGuard': true,
'backdoorPackCacheIsolation': true,
'backdoorAllowLocalHttpWhenServerLocal': true,
'backdoorAllowedTranslationPacks': [
'vanilla',
],
'backdoorAllowedKeybinds': [
],
}

View file

@ -60,7 +60,6 @@ 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;
@ -318,7 +317,6 @@ public class LoVisual implements ClientModInitializer {
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);

View file

@ -0,0 +1,55 @@
package dev.loki.lovisual.features.cosmetic;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
public final class CosmeticAttachmentTransforms {
private CosmeticAttachmentTransforms() {
}
public static void apply(PoseStack matrices, CosmeticType type, CosmeticModel model) {
if (type == CosmeticType.PET) {
// TODO: replace with CosmeticAnimator.getPetHoverOffset() once the animator is ported.
float bob = 0f;
matrices.mulPose(Axis.YP.rotationDegrees(180f));
matrices.translate(0.45f, 2.15f + bob, 0.12f);
matrices.scale(0.55f, 0.55f, 0.55f);
return;
}
if (type == CosmeticType.ACCESSORY && model != null) {
String folder = model.getFolderName().toLowerCase();
if (folder.contains("witch") || folder.contains("hat")) {
matrices.translate(0f, 1.72f, 0f);
matrices.scale(0.52f, 0.52f, 0.52f);
} else if (folder.contains("wing")) {
matrices.translate(0f, 0.1f, -0.3f);
matrices.mulPose(Axis.YP.rotationDegrees(180f));
matrices.scale(0.85f, 0.85f, 0.85f);
} else if (folder.contains("wheel")) {
matrices.translate(0f, 0.55f, 0.25f);
matrices.scale(0.55f, 0.55f, 0.55f);
} else {
matrices.translate(0f, 1.3f, 0f);
matrices.scale(0.5f, 0.5f, 0.5f);
}
}
}
public static void applyPreview(PoseStack matrices, CosmeticModel model) {
if (model == null) return;
String folder = model.getFolderName().toLowerCase();
if (model.getType() == CosmeticType.PET) {
matrices.translate(6f, 22f, 1f);
matrices.scale(1.8f, 1.8f, 1.8f);
} else if (model.getType() == CosmeticType.ACCESSORY) {
if (folder.contains("witch") || folder.contains("hat")) {
matrices.translate(0f, 20f, 0f);
} else if (folder.contains("wing")) {
matrices.translate(0f, 2f, -4f);
matrices.scale(1.3f, 1.3f, 1.3f);
} else if (folder.contains("wheel")) {
matrices.translate(0f, 6f, 3f);
}
}
}
}

View file

@ -0,0 +1,223 @@
package dev.loki.lovisual.features.cosmetic;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class CosmeticBbAnimation {
private final String name;
private final float length;
private final boolean loop;
private final Map<String, BoneTrack> tracksByBoneUuid = new java.util.HashMap<>();
public CosmeticBbAnimation(String name, float length, boolean loop) {
this.name = name;
this.length = Math.max(length, 1f / 20f);
this.loop = loop;
}
public String getName() {
return name;
}
public float getLength() {
return length;
}
public boolean isLoop() {
return loop;
}
public Map<String, BoneTrack> getTracks() {
return tracksByBoneUuid;
}
public static Map<String, CosmeticBbAnimation> parseAll(JsonObject bbmodel) {
Map<String, CosmeticBbAnimation> map = new LinkedHashMap<>();
if (!bbmodel.has("animations")) return map;
for (JsonElement el : bbmodel.getAsJsonArray("animations")) {
if (!el.isJsonObject()) continue;
JsonObject obj = el.getAsJsonObject();
String name = obj.has("name") ? obj.get("name").getAsString() : "anim";
if (name.startsWith("--") || name.isBlank()) continue;
float length = obj.has("length") ? obj.get("length").getAsFloat() : 1f;
boolean loop = !obj.has("loop") || obj.get("loop").getAsBoolean();
CosmeticBbAnimation anim = new CosmeticBbAnimation(name, length, loop);
if (obj.has("animators")) {
JsonObject animators = obj.getAsJsonObject("animators");
for (Map.Entry<String, JsonElement> entry : animators.entrySet()) {
if (!entry.getValue().isJsonObject()) continue;
JsonObject boneAnim = entry.getValue().getAsJsonObject();
String boneUuid = entry.getKey();
if (boneAnim.has("uuid")) {
boneUuid = boneAnim.get("uuid").getAsString();
}
BoneTrack track = BoneTrack.parse(boneAnim);
if (!track.isEmpty()) {
anim.tracksByBoneUuid.put(boneUuid, track);
}
}
}
if (anim.tracksByBoneUuid.isEmpty()) continue;
map.put(name.toLowerCase(), anim);
}
return map;
}
public static void apply(CosmeticModel model, CosmeticBbAnimation animation, float time) {
java.util.Set<String> filter = model.getAnimationBoneFilter();
for (Map.Entry<String, BoneTrack> entry : animation.tracksByBoneUuid.entrySet()) {
if (filter != null && !filter.contains(entry.getKey())) continue;
CosmeticBone bone = model.getBoneByUuid(entry.getKey());
if (bone == null) continue;
BoneTrack track = entry.getValue();
Vector3f rot = track.sampleRotation(time, animation.length, animation.loop);
Vector3f pos = track.samplePosition(time, animation.length, animation.loop);
if (rot != null) {
bone.setAnimRotation(new Vector3f(
rot.x - bone.getDefaultRotation().x,
rot.y - bone.getDefaultRotation().y,
rot.z - bone.getDefaultRotation().z
));
}
if (pos != null) {
bone.setAnimPosition(pos);
}
}
}
public static final class BoneTrack {
private final List<Keyframe> rotations = new ArrayList<>();
private final List<Keyframe> positions = new ArrayList<>();
static BoneTrack parse(JsonObject boneAnim) {
BoneTrack track = new BoneTrack();
if (!boneAnim.has("keyframes")) return track;
for (JsonElement el : boneAnim.getAsJsonArray("keyframes")) {
if (!el.isJsonObject()) continue;
JsonObject kf = el.getAsJsonObject();
String channel = kf.has("channel") ? kf.get("channel").getAsString() : "";
float t = kf.has("time") ? kf.get("time").getAsFloat() : 0f;
Vector3f value = readPoint(kf);
String interp = kf.has("interpolation") ? kf.get("interpolation").getAsString() : "linear";
Keyframe keyframe = new Keyframe(t, value, interp);
switch (channel) {
case "rotation" -> track.rotations.add(keyframe);
case "position" -> track.positions.add(keyframe);
default -> {
}
}
}
track.rotations.sort(Comparator.comparing(Keyframe::time));
track.positions.sort(Comparator.comparing(Keyframe::time));
return track;
}
boolean isEmpty() {
return rotations.isEmpty() && positions.isEmpty();
}
Vector3f sampleRotation(float time, float length, boolean loop) {
return sample(rotations, time, length, loop);
}
Vector3f samplePosition(float time, float length, boolean loop) {
return sample(positions, time, length, loop);
}
private static Vector3f sample(List<Keyframe> frames, float time, float length, boolean loop) {
if (frames.isEmpty()) return null;
if (frames.size() == 1) return new Vector3f(frames.get(0).value);
float t = loop ? wrapTime(time, length) : Math.min(time, length);
Keyframe prev = frames.get(0);
Keyframe next = frames.get(frames.size() - 1);
for (int i = 0; i < frames.size() - 1; i++) {
Keyframe a = frames.get(i);
Keyframe b = frames.get(i + 1);
if (t >= a.time && t <= b.time) {
prev = a;
next = b;
break;
}
}
if (t >= frames.get(frames.size() - 1).time) {
prev = frames.get(frames.size() - 1);
next = prev;
}
if (prev == next || Math.abs(next.time - prev.time) < 0.0001f) {
return new Vector3f(prev.value);
}
float alpha = (t - prev.time) / (next.time - prev.time);
if ("step".equals(prev.interpolation)) {
return new Vector3f(prev.value);
}
return lerp(prev.value, next.value, clamp01(alpha));
}
private static float wrapTime(float time, float length) {
if (length <= 0f) return time;
float wrapped = time % length;
return wrapped < 0f ? wrapped + length : wrapped;
}
private static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
private static Vector3f lerp(Vector3f a, Vector3f b, float t) {
return new Vector3f(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
a.z + (b.z - a.z) * t
);
}
private static Vector3f readPoint(JsonObject kf) {
if (!kf.has("data_points")) return new Vector3f();
JsonArray points = kf.getAsJsonArray("data_points");
if (points.isEmpty()) return new Vector3f();
JsonObject point = points.get(0).getAsJsonObject();
return new Vector3f(
parseNum(point, "x"),
parseNum(point, "y"),
parseNum(point, "z")
);
}
private static float parseNum(JsonObject obj, String key) {
if (!obj.has(key)) return 0f;
JsonElement el = obj.get(key);
if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isNumber()) {
return el.getAsFloat();
}
try {
return Float.parseFloat(el.getAsString().trim());
} catch (Exception ignored) {
return 0f;
}
}
}
private record Keyframe(float time, Vector3f value, String interpolation) {
}
}

View file

@ -0,0 +1,66 @@
package dev.loki.lovisual.features.cosmetic;
import lombok.Getter;
import lombok.Setter;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.List;
@Getter
public class CosmeticBone {
private final String name;
private final String uuid;
private final Vector3f pivot;
private final Vector3f defaultRotation;
private final List<CosmeticCube> cubes = new ArrayList<>();
private final List<CosmeticBone> children = new ArrayList<>();
@Setter private Vector3f animRotation = new Vector3f(0, 0, 0);
@Setter private Vector3f animPosition = new Vector3f(0, 0, 0);
public CosmeticBone(String name, String uuid, Vector3f pivot, Vector3f defaultRotation) {
this.name = name;
this.uuid = uuid;
this.pivot = pivot;
this.defaultRotation = defaultRotation;
}
public Matrix4f buildMatrix() {
Matrix4f mat = new Matrix4f();
mat.translate(pivot.x / 16f, pivot.y / 16f, pivot.z / 16f);
if (defaultRotation.x != 0 || defaultRotation.y != 0 || defaultRotation.z != 0
|| animRotation.x != 0 || animRotation.y != 0 || animRotation.z != 0) {
float rx = (float) Math.toRadians(defaultRotation.x + animRotation.x);
float ry = (float) Math.toRadians(defaultRotation.y + animRotation.y);
float rz = (float) Math.toRadians(defaultRotation.z + animRotation.z);
mat.rotateZYX(rz, ry, rx);
}
mat.translate(-pivot.x / 16f, -pivot.y / 16f, -pivot.z / 16f);
if (animPosition.x != 0 || animPosition.y != 0 || animPosition.z != 0) {
mat.translate(animPosition.x / 16f, animPosition.y / 16f, animPosition.z / 16f);
}
return mat;
}
public void resetAnim() {
animRotation = new Vector3f(0, 0, 0);
animPosition = new Vector3f(0, 0, 0);
for (CosmeticBone child : children) child.resetAnim();
}
public CosmeticBone deepClone() {
CosmeticBone copy = new CosmeticBone(name, uuid, new Vector3f(pivot), new Vector3f(defaultRotation));
copy.getCubes().addAll(cubes);
for (CosmeticBone child : children) {
copy.getChildren().add(child.deepClone());
}
return copy;
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.lovisual.features.cosmetic;
import lombok.Getter;
import org.joml.Vector3f;
@Getter
public class CosmeticCube {
private final Vector3f from;
private final Vector3f to;
private final Vector3f rotation;
private final Vector3f origin;
private final float inflate;
private final CubeFace[] faces;
private final int textureWidth;
private final int textureHeight;
public CosmeticCube(Vector3f from, Vector3f to, Vector3f rotation, Vector3f origin,
float inflate, CubeFace[] faces, int textureWidth, int textureHeight) {
this.from = from;
this.to = to;
this.rotation = rotation;
this.origin = origin;
this.inflate = inflate;
this.faces = faces;
this.textureWidth = textureWidth;
this.textureHeight = textureHeight;
}
@Getter
public static class CubeFace {
private final float u1, v1, u2, v2;
private final int textureIndex;
public CubeFace(float u1, float v1, float u2, float v2, int textureIndex) {
this.u1 = u1;
this.v1 = v1;
this.u2 = u2;
this.v2 = v2;
this.textureIndex = textureIndex;
}
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.lovisual.features.cosmetic;
/**
* Holds which cosmetic (model/pet/accessory) the local user currently has equipped.
* Rendering and animation live in separate classes (not part of this core pass).
*/
public final class CosmeticManager {
private static final CosmeticManager INSTANCE = new CosmeticManager();
private CosmeticModel activeModel;
private CosmeticModel activePet;
private CosmeticModel activeAccessory;
private CosmeticManager() {
}
public static CosmeticManager get() {
return INSTANCE;
}
/**
* Reloads the catalog from disk, re-resolving the currently equipped cosmetics
* (by id) against the freshly loaded models.
*/
public void init() {
String modelId = activeModel != null ? activeModel.getId() : null;
String petId = activePet != null ? activePet.getId() : null;
String accessoryId = activeAccessory != null ? activeAccessory.getId() : null;
for (CosmeticModel model : CosmeticRepository.get().all()) {
model.resetTexture();
}
CosmeticRepository.get().reload();
CosmeticRepository repo = CosmeticRepository.get();
activeModel = modelId != null ? repo.get(modelId) : null;
activePet = petId != null ? repo.get(petId) : null;
activeAccessory = accessoryId != null ? repo.get(accessoryId) : null;
}
public CosmeticModel getActive(CosmeticType type) {
return switch (type) {
case PET -> activePet;
case ACCESSORY -> activeAccessory;
case MODEL -> activeModel;
};
}
public void setActive(CosmeticType type, CosmeticModel model) {
if (model != null) {
CosmeticModel resolved = CosmeticRepository.get().get(model.getId());
if (resolved != null) {
model = resolved;
}
}
switch (type) {
case PET -> activePet = model;
case ACCESSORY -> activeAccessory = model;
case MODEL -> activeModel = model;
}
}
public void clear(CosmeticType type) {
setActive(type, null);
}
public boolean hasAnyActive() {
return activeModel != null || activePet != null || activeAccessory != null;
}
public boolean isActive() {
return activeModel != null;
}
}

View file

@ -0,0 +1,659 @@
package dev.loki.lovisual.features.cosmetic;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.blaze3d.platform.NativeImage;
import lombok.Getter;
import lombok.Setter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import org.joml.Vector3f;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* A single cosmetic asset: a Blockbench (.bbmodel) bone/cube tree plus its texture(s),
* loaded from a user-provided folder (avatar.json + model.bbmodel + textures).
*/
@Getter
public class CosmeticModel {
private final String id;
private final String name;
private final String description;
private final String accentColor;
private final String folderName;
private final CosmeticType type;
private final File externalDirectory;
@Setter private String source = "Своя";
private final List<CosmeticBone> rootBones = new ArrayList<>();
private final List<CosmeticBone> renderRoots = new ArrayList<>();
private boolean customLegsPresent;
private boolean bowReplacementAccessory;
private boolean swordReplacementAccessory;
private boolean hatOnlyAccessory;
private boolean wingsOnlyAccessory;
private Set<String> visibleBoneUuids;
@Setter private Set<String> animationBoneFilter;
private final Map<String, CosmeticBone> boneMap = new HashMap<>();
private final Map<String, CosmeticBone> boneMapByUuid = new HashMap<>();
private final Map<String, CosmeticBbAnimation> bbAnimations = new LinkedHashMap<>();
private final List<CosmeticTextureSlot> textureSlots = new ArrayList<>();
private int textureWidth = 64;
private int textureHeight = 64;
private Identifier textureId;
private boolean textureLoaded = false;
private CosmeticTextureSlot fallbackSlot;
private CosmeticModel(String id, String name, String description, String accentColor,
String folderName, CosmeticType type, File externalDirectory) {
this.id = id;
this.name = name;
this.description = description;
this.accentColor = accentColor;
this.folderName = folderName;
this.type = type;
this.externalDirectory = externalDirectory;
}
public CosmeticModel withType(CosmeticType newType) {
CosmeticModel copy = new CosmeticModel(id, name, description, accentColor, folderName, newType, externalDirectory);
copy.rootBones.addAll(rootBones);
copy.renderRoots.addAll(renderRoots);
copy.boneMap.putAll(boneMap);
copy.boneMapByUuid.putAll(boneMapByUuid);
copy.bbAnimations.putAll(bbAnimations);
copy.textureSlots.addAll(textureSlots);
copy.textureWidth = textureWidth;
copy.textureHeight = textureHeight;
copy.textureId = textureId;
copy.textureLoaded = textureLoaded;
copy.source = source;
copy.postProcess();
return copy;
}
public void resetTexture() {
textureLoaded = false;
textureId = null;
for (CosmeticTextureSlot slot : textureSlots) {
slot.reset();
}
}
public CosmeticTextureSlot getTextureSlot(int index) {
if (!textureSlots.isEmpty()) {
if (index >= 0 && index < textureSlots.size()) {
return textureSlots.get(index);
}
return textureSlots.get(0);
}
if (textureId == null) return null;
if (fallbackSlot == null) {
fallbackSlot = new CosmeticTextureSlot(0, textureId, textureWidth, textureHeight, "fallback");
if (textureLoaded) fallbackSlot.markLoaded();
}
return fallbackSlot;
}
/**
* Loads a cosmetic from a user folder: {@code avatar.json} (metadata) plus a
* {@code .bbmodel} (geometry/animation) and its texture(s).
*/
public static CosmeticModel loadExternal(File folder, String sourceTag) {
if (folder == null || !folder.isDirectory()) return null;
String key = sourceTag + ":" + folder.getName().toLowerCase();
return load(folder.getName(), folder, key, null);
}
private static CosmeticModel load(String folderName, File externalDir, String catalogId, CosmeticType forcedType) {
try {
JsonObject avatar = new JsonObject();
File avatarFile = new File(externalDir, "avatar.json");
if (avatarFile.exists()) {
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(avatarFile), StandardCharsets.UTF_8)) {
avatar = JsonParser.parseReader(reader).getAsJsonObject();
}
}
String name = avatar.has("name") ? avatar.get("name").getAsString() : folderName;
String desc = avatar.has("description") ? avatar.get("description").getAsString() : "";
String color = avatar.has("color") ? avatar.get("color").getAsString() : "#ffffff";
CosmeticType type = forcedType != null ? forcedType : CosmeticType.fromJson(
avatar.has("category") ? avatar.get("category").getAsString()
: avatar.has("type") ? avatar.get("type").getAsString() : null
);
CosmeticModel model = new CosmeticModel(catalogId, name, desc, color, folderName, type, externalDir);
InputStream modelStream = openModelStream(externalDir, folderName);
if (modelStream == null) {
return model;
}
JsonObject bbmodel;
try (InputStreamReader reader = new InputStreamReader(modelStream, StandardCharsets.UTF_8)) {
bbmodel = JsonParser.parseReader(reader).getAsJsonObject();
}
if (bbmodel.has("resolution")) {
JsonObject res = bbmodel.getAsJsonObject("resolution");
model.textureWidth = res.get("width").getAsInt();
model.textureHeight = res.get("height").getAsInt();
}
model.textureId = Identifier.fromNamespaceAndPath(
"lovisual",
"dynamic/cosmetic/" + catalogId.replace(':', '_').replace(' ', '_')
);
model.loadTextureSlots(bbmodel);
Map<String, JsonObject> elementMap = new HashMap<>();
if (bbmodel.has("elements")) {
for (JsonElement el : bbmodel.getAsJsonArray("elements")) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("uuid")) {
elementMap.put(obj.get("uuid").getAsString(), obj);
}
}
}
Map<String, JsonObject> groupMap = buildGroupMap(bbmodel);
if (bbmodel.has("outliner")) {
for (JsonElement el : bbmodel.getAsJsonArray("outliner")) {
CosmeticBone bone = parseOutliner(el, elementMap, groupMap, model.textureWidth, model.textureHeight);
if (bone != null) {
model.rootBones.add(bone);
model.indexBone(bone);
}
}
}
model.bbAnimations.putAll(CosmeticBbAnimation.parseAll(bbmodel));
model.postProcess();
return model;
} catch (Exception e) {
return null;
}
}
private static InputStream openModelStream(File externalDir, String folderName) throws IOException {
File model = new File(externalDir, "model.bbmodel");
if (!model.exists()) {
model = new File(externalDir, folderName + ".bbmodel");
}
if (model.exists()) return new FileInputStream(model);
File[] bbmodels = externalDir.listFiles((dir, name) -> name.endsWith(".bbmodel"));
if (bbmodels != null && bbmodels.length > 0) {
return new FileInputStream(bbmodels[0]);
}
return null;
}
private void loadTextureSlots(JsonObject bbmodel) {
textureSlots.clear();
if (!bbmodel.has("textures")) return;
String modelKey = id.replace(':', '_');
JsonArray textures = bbmodel.getAsJsonArray("textures");
for (int i = 0; i < textures.size(); i++) {
if (!textures.get(i).isJsonObject()) continue;
JsonObject tex = textures.get(i).getAsJsonObject();
textureSlots.add(CosmeticTextureSlot.fromJson(i, modelKey, tex));
}
}
public void ensureTextureSlotLoaded(CosmeticTextureSlot slot) {
if (slot == null || slot.isLoaded()) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
slot.load(mc, externalDirectory, folderName);
}
/**
* Loads whichever textures this model needs (per-slot bbmodel textures, or a single
* fallback {@code texture.png}/{@code avatar.png} in the cosmetic's folder).
*/
public void loadTexture() {
if (textureLoaded) return;
try {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getTextureManager() == null) return;
boolean anyLoaded = false;
for (CosmeticTextureSlot slot : textureSlots) {
slot.load(mc, externalDirectory, folderName);
if (slot.isLoaded()) anyLoaded = true;
}
if (!anyLoaded && externalDirectory != null) {
String[] names = {"outfits/texture.png", "texture.png", folderName + ".png", "avatar.png"};
for (String rel : names) {
File tex = new File(externalDirectory, rel);
if (!tex.exists()) continue;
try (InputStream is = new FileInputStream(tex)) {
registerTexture(mc, textureId, is);
anyLoaded = true;
break;
} catch (Exception ignored) {
}
}
} else if (!textureSlots.isEmpty() && textureSlots.get(0).isLoaded()) {
textureId = textureSlots.get(0).getId();
textureWidth = textureSlots.get(0).getWidth();
textureHeight = textureSlots.get(0).getHeight();
}
textureLoaded = anyLoaded;
if (!anyLoaded) {
textureId = null;
}
} catch (Exception ignored) {
}
}
private void registerTexture(Minecraft mc, Identifier rawId, InputStream is) throws IOException {
NativeImage img = NativeImage.read(is);
DynamicTexture tex = new DynamicTexture(() -> "lovisual_cosmetic", img);
mc.getTextureManager().register(rawId, tex);
textureId = rawId;
textureLoaded = true;
}
private void indexBone(CosmeticBone bone) {
boneMap.put(bone.getName().toLowerCase(), bone);
boneMapByUuid.put(bone.getUuid(), bone);
for (CosmeticBone child : bone.getChildren()) indexBone(child);
}
public CosmeticBone getBone(String name) {
return boneMap.get(name.toLowerCase());
}
public CosmeticBone getBoneByUuid(String uuid) {
return boneMapByUuid.get(uuid);
}
public boolean hasBbAnimations() {
return !bbAnimations.isEmpty();
}
public Collection<CosmeticBbAnimation> getBbAnimations() {
return bbAnimations.values();
}
public CosmeticBbAnimation findBbAnimation(String... candidates) {
for (String candidate : candidates) {
if (candidate == null) continue;
CosmeticBbAnimation anim = bbAnimations.get(candidate.toLowerCase());
if (anim != null) return anim;
}
for (String candidate : candidates) {
if (candidate == null) continue;
String lower = candidate.toLowerCase();
for (Map.Entry<String, CosmeticBbAnimation> entry : bbAnimations.entrySet()) {
if (entry.getKey().contains(lower) || lower.contains(entry.getKey())) {
return entry.getValue();
}
}
}
return null;
}
public CosmeticBbAnimation getFirstBbAnimation() {
for (String preferred : List.of(
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
"walking", "yap", "painting", "pose", "pose2"
)) {
CosmeticBbAnimation anim = findBbAnimation(preferred);
if (anim != null) return anim;
}
if (bbAnimations.isEmpty()) return null;
return bbAnimations.values().iterator().next();
}
public List<CosmeticBone> getRenderRoots() {
return renderRoots.isEmpty() ? rootBones : renderRoots;
}
public boolean hasCustomLegs() {
return customLegsPresent;
}
public boolean isBowReplacementAccessory() {
return bowReplacementAccessory;
}
public boolean isSwordReplacementAccessory() {
return swordReplacementAccessory;
}
public boolean isBodyOnlyAccessory() {
return type == CosmeticType.ACCESSORY && !bowReplacementAccessory && !swordReplacementAccessory;
}
public boolean hasRestPoseAnimation() {
return findBbAnimation(
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
"walking", "walkingback", "flywalk"
) != null;
}
public boolean shouldSkipBone(CosmeticBone bone) {
if (visibleBoneUuids != null) {
return !visibleBoneUuids.contains(bone.getUuid());
}
String lower = bone.getName().toLowerCase();
if (customLegsPresent && lower.contains("vanilla") && (lower.contains("leg") || lower.contains("foot"))) {
return true;
}
if (lower.contains("rightarm_fp") || lower.contains("leftarm_fp")) {
return true;
}
if (hatOnlyAccessory) {
return lower.equals("body") || lower.equals("leftarm") || lower.equals("rightarm")
|| lower.equals("leftleg") || lower.equals("rightleg") || lower.equals("root");
}
return false;
}
/**
* Derives a standalone pet cosmetic from a single named bone subtree of a parent model
* (e.g. turning a model's "head" bone into its own pet cosmetic).
*/
public static CosmeticModel derivePet(CosmeticModel parent, String boneName, String petFolder, String petName) {
if (parent == null) return null;
CosmeticBone source = parent.findBoneDeep(boneName);
if (source == null) return null;
CosmeticModel pet = new CosmeticModel(
"derived:" + petFolder.replace('/', '_'),
petName,
"",
parent.getAccentColor(),
petFolder,
CosmeticType.PET,
null
);
pet.source = parent.source;
pet.textureWidth = parent.textureWidth;
pet.textureHeight = parent.textureHeight;
pet.textureId = parent.textureId;
pet.textureSlots.addAll(parent.textureSlots);
pet.textureLoaded = parent.textureLoaded;
CosmeticBone root = source.deepClone();
pet.rootBones.add(root);
pet.renderRoots.add(root);
pet.indexBone(root);
Set<String> uuids = new HashSet<>();
pet.collectUuids(root, uuids);
pet.animationBoneFilter = uuids;
for (Map.Entry<String, CosmeticBbAnimation> entry : parent.bbAnimations.entrySet()) {
String key = entry.getKey().toLowerCase();
if (key.contains("mons") || key.contains("mini")) {
pet.bbAnimations.put(entry.getKey(), entry.getValue());
}
}
if (pet.bbAnimations.isEmpty()) {
pet.bbAnimations.putAll(parent.bbAnimations);
}
pet.postProcess();
return pet;
}
private void collectUuids(CosmeticBone bone, Set<String> uuids) {
uuids.add(bone.getUuid());
for (CosmeticBone child : bone.getChildren()) collectUuids(child, uuids);
}
private CosmeticBone findBoneDeep(String name) {
String lower = name.toLowerCase();
for (CosmeticBone root : rootBones) {
CosmeticBone found = findBoneDeep(root, lower);
if (found != null) return found;
}
return null;
}
private static CosmeticBone findBoneDeep(CosmeticBone bone, String lower) {
if (bone.getName().toLowerCase().equals(lower) || bone.getName().toLowerCase().contains(lower)) {
return bone;
}
for (CosmeticBone child : bone.getChildren()) {
CosmeticBone found = findBoneDeep(child, lower);
if (found != null) return found;
}
return null;
}
private void postProcess() {
customLegsPresent = boneMap.keySet().stream().anyMatch(n ->
n.contains("leftleg") || n.contains("rightleg") || n.contains("leftpaw") || n.contains("rightpaw"))
&& boneMap.keySet().stream().noneMatch(n -> n.equals("leftlegvanilla") || n.equals("rightlegvanilla")
|| (n.contains("vanilla") && n.contains("leg")));
String folder = folderName.toLowerCase();
bowReplacementAccessory = type == CosmeticType.ACCESSORY && folder.contains("bow");
swordReplacementAccessory = false;
hatOnlyAccessory = type == CosmeticType.ACCESSORY
&& (folder.contains("witch") || folder.contains("hat")) && !bowReplacementAccessory;
wingsOnlyAccessory = type == CosmeticType.ACCESSORY && folder.contains("wing");
visibleBoneUuids = null;
if (wingsOnlyAccessory) {
visibleBoneUuids = buildWingsBoneChainUuids();
}
renderRoots.clear();
for (CosmeticBone root : rootBones) {
String n = root.getName().toLowerCase();
if (type == CosmeticType.MODEL) {
if (n.equals("monst3r") || n.equals("skull") || n.endsWith("_fp")) continue;
}
if (type == CosmeticType.PET) {
if (n.equals("arrow") || n.equals("skull")) continue;
}
renderRoots.add(root);
}
if (renderRoots.isEmpty()) {
renderRoots.addAll(rootBones);
}
applyAccessorySubtreeFilter();
}
private void applyAccessorySubtreeFilter() {
if (hatOnlyAccessory) {
CosmeticBone head = findBoneDeep("head");
CosmeticBone hatBone = findBoneDeep("bone");
if (head != null && hatBone != null) {
CosmeticBone mount = new CosmeticBone(
head.getName(), head.getUuid(),
new Vector3f(head.getPivot()), new Vector3f(head.getDefaultRotation()));
mount.getChildren().add(hatBone.deepClone());
renderRoots.clear();
renderRoots.add(mount);
}
}
}
private Set<String> buildWingsBoneChainUuids() {
Set<String> keep = new HashSet<>();
CosmeticBone wings = findBoneDeep("wings");
if (wings == null) return keep;
collectBoneTreeUuids(wings, keep);
for (CosmeticBone root : rootBones) {
markBonePathToUuid(root, wings.getUuid(), keep);
}
return keep;
}
private static void collectBoneTreeUuids(CosmeticBone bone, Set<String> uuids) {
uuids.add(bone.getUuid());
for (CosmeticBone child : bone.getChildren()) {
collectBoneTreeUuids(child, uuids);
}
}
private static boolean markBonePathToUuid(CosmeticBone bone, String targetUuid, Set<String> keep) {
keep.add(bone.getUuid());
if (bone.getUuid().equals(targetUuid)) {
return true;
}
for (CosmeticBone child : bone.getChildren()) {
if (markBonePathToUuid(child, targetUuid, keep)) {
return true;
}
}
keep.remove(bone.getUuid());
return false;
}
private static Map<String, JsonObject> buildGroupMap(JsonObject bbmodel) {
Map<String, JsonObject> map = new HashMap<>();
if (!bbmodel.has("groups")) return map;
for (JsonElement el : bbmodel.getAsJsonArray("groups")) {
if (!el.isJsonObject()) continue;
JsonObject group = el.getAsJsonObject();
if (group.has("uuid")) {
map.put(group.get("uuid").getAsString(), group);
}
}
return map;
}
private static Vector3f readVec3(JsonObject obj, String key, Vector3f fallback) {
if (obj == null || !obj.has(key)) return new Vector3f(fallback);
JsonArray arr = obj.getAsJsonArray(key);
return new Vector3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
}
private static CosmeticBone parseOutliner(JsonElement element, Map<String, JsonObject> elementMap,
Map<String, JsonObject> groupMap, int texW, int texH) {
if (element.isJsonPrimitive() || !element.isJsonObject()) return null;
JsonObject obj = element.getAsJsonObject();
String uuid = obj.has("uuid") ? obj.get("uuid").getAsString() : UUID.randomUUID().toString();
JsonObject group = groupMap.get(uuid);
String name = "bone";
if (obj.has("name")) {
name = obj.get("name").getAsString();
} else if (group != null && group.has("name")) {
name = group.get("name").getAsString();
}
Vector3f pivot = obj.has("origin")
? readVec3(obj, "origin", new Vector3f())
: readVec3(group, "origin", new Vector3f());
Vector3f rotation = obj.has("rotation")
? readVec3(obj, "rotation", new Vector3f())
: readVec3(group, "rotation", new Vector3f());
CosmeticBone bone = new CosmeticBone(name, uuid, pivot, rotation);
if (obj.has("children")) {
for (JsonElement child : obj.getAsJsonArray("children")) {
if (child.isJsonPrimitive()) {
String childUuid = child.getAsString();
JsonObject cubeObj = elementMap.get(childUuid);
if (cubeObj != null) {
CosmeticCube cube = parseCube(cubeObj, texW, texH);
if (cube != null) bone.getCubes().add(cube);
}
} else {
CosmeticBone childBone = parseOutliner(child, elementMap, groupMap, texW, texH);
if (childBone != null) bone.getChildren().add(childBone);
}
}
}
return bone;
}
private static CosmeticCube parseCube(JsonObject obj, int texW, int texH) {
try {
JsonArray fromArr = obj.getAsJsonArray("from");
JsonArray toArr = obj.getAsJsonArray("to");
Vector3f from = new Vector3f(fromArr.get(0).getAsFloat(), fromArr.get(1).getAsFloat(), fromArr.get(2).getAsFloat());
Vector3f to = new Vector3f(toArr.get(0).getAsFloat(), toArr.get(1).getAsFloat(), toArr.get(2).getAsFloat());
Vector3f rot = new Vector3f(0, 0, 0);
if (obj.has("rotation")) {
JsonArray r = obj.getAsJsonArray("rotation");
rot.set(r.get(0).getAsFloat(), r.get(1).getAsFloat(), r.get(2).getAsFloat());
}
Vector3f origin = new Vector3f(0, 0, 0);
if (obj.has("origin")) {
JsonArray o = obj.getAsJsonArray("origin");
origin.set(o.get(0).getAsFloat(), o.get(1).getAsFloat(), o.get(2).getAsFloat());
}
float inflate = obj.has("inflate") ? obj.get("inflate").getAsFloat() : 0f;
CosmeticCube.CubeFace[] faces = new CosmeticCube.CubeFace[6];
String[] faceNames = {"north", "east", "south", "west", "up", "down"};
if (obj.has("faces")) {
JsonObject facesObj = obj.getAsJsonObject("faces");
for (int i = 0; i < faceNames.length; i++) {
String fn = faceNames[i];
if (!facesObj.has(fn)) continue;
JsonObject face = facesObj.getAsJsonObject(fn);
if (!face.has("uv") || !face.get("uv").isJsonArray()) continue;
JsonArray uv = face.getAsJsonArray("uv");
if (uv.size() < 4) continue;
int tex = 0;
if (face.has("texture") && !face.get("texture").isJsonNull()) {
JsonElement texEl = face.get("texture");
if (texEl.isJsonPrimitive() && texEl.getAsJsonPrimitive().isNumber()) {
tex = texEl.getAsInt();
}
}
float u1 = uv.get(0).getAsFloat();
float v1 = uv.get(1).getAsFloat();
float u2 = uv.get(2).getAsFloat();
float v2 = uv.get(3).getAsFloat();
if (u2 < u1) {
float t = u1;
u1 = u2;
u2 = t;
}
if (v2 < v1) {
float t = v1;
v1 = v2;
v2 = t;
}
faces[i] = new CosmeticCube.CubeFace(u1, v1, u2, v2, tex);
}
}
return new CosmeticCube(from, to, rot, origin, inflate, faces, texW, texH);
} catch (Exception e) {
return null;
}
}
}

View file

@ -0,0 +1,204 @@
package dev.loki.lovisual.features.cosmetic;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.AbstractTexture;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Renders a rest-pose (no bb-animation) {@link CosmeticModel} bone/cube tree attached to a
* player, as camera-relative textured quads through the engine's {@link Renderer3D}/{@link MeshBuilder}.
*/
public final class CosmeticRenderer {
private static final float UNITS_PER_BLOCK = 16.0f;
private CosmeticRenderer() {
}
public static void renderOnPlayer(Renderer3D renderer, Player player, CosmeticType type, float tickDelta) {
if (renderer == null || player == null) return;
CosmeticModel model = CosmeticManager.get().getActive(type);
if (model == null) return;
model.loadTexture();
List<CosmeticBone> roots = model.getRenderRoots();
if (roots.isEmpty()) return;
Vec3 pos = RenderMath.getLerpedPos(player, tickDelta);
float bodyYaw = Mth.rotLerp(tickDelta, player.yBodyRotO, player.yBodyRot);
PoseStack matrices = new PoseStack();
matrices.translate(pos.x, pos.y, pos.z);
CosmeticAttachmentTransforms.apply(matrices, type, model);
matrices.mulPose(Axis.YP.rotationDegrees(180f - bodyYaw));
Map<Integer, List<PendingFace>> byTexture = new HashMap<>();
for (CosmeticBone bone : roots) {
collectBone(matrices, model, bone, byTexture);
}
if (byTexture.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
Vec3 cam = RenderState.cameraPos;
for (Map.Entry<Integer, List<PendingFace>> entry : byTexture.entrySet()) {
CosmeticTextureSlot slot = model.getTextureSlot(entry.getKey());
if (slot == null) continue;
if (!slot.isLoaded()) {
model.ensureTextureSlotLoaded(slot);
}
if (!slot.isLoaded()) continue;
AbstractTexture texture = mc.getTextureManager().getTexture(slot.getId());
if (texture == null || texture.getTextureView() == null || texture.getSampler() == null) continue;
Renderer3D.BatchBindings bindings = Renderer3D.BatchBindings.none()
.withSampler("u_Texture", texture.getTextureView(), texture.getSampler());
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_TEXTURED, Renderer3D.DepthMode.MAIN, bindings);
if (mesh == null) continue;
for (PendingFace face : entry.getValue()) {
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(face.xs[0] - cam.x, face.ys[0] - cam.y, face.zs[0] - cam.z)
.raw2(face.us[0], face.vs[0]).color(255, 255, 255, 255).next();
int i2 = mesh.vec3(face.xs[1] - cam.x, face.ys[1] - cam.y, face.zs[1] - cam.z)
.raw2(face.us[1], face.vs[1]).color(255, 255, 255, 255).next();
int i3 = mesh.vec3(face.xs[2] - cam.x, face.ys[2] - cam.y, face.zs[2] - cam.z)
.raw2(face.us[2], face.vs[2]).color(255, 255, 255, 255).next();
int i4 = mesh.vec3(face.xs[3] - cam.x, face.ys[3] - cam.y, face.zs[3] - cam.z)
.raw2(face.us[3], face.vs[3]).color(255, 255, 255, 255).next();
mesh.quad(i1, i2, i3, i4);
}
}
}
private static void collectBone(PoseStack matrices, CosmeticModel model, CosmeticBone bone,
Map<Integer, List<PendingFace>> out) {
if (model.shouldSkipBone(bone)) return;
matrices.pushPose();
matrices.last().pose().mul(bone.buildMatrix());
for (CosmeticCube cube : bone.getCubes()) {
collectCube(matrices, cube, out);
}
for (CosmeticBone child : bone.getChildren()) {
collectBone(matrices, model, child, out);
}
matrices.popPose();
}
private static void collectCube(PoseStack matrices, CosmeticCube cube, Map<Integer, List<PendingFace>> out) {
matrices.pushPose();
Vector3f origin = cube.getOrigin();
Vector3f rotation = cube.getRotation();
if (rotation.x != 0f || rotation.y != 0f || rotation.z != 0f) {
matrices.translate(origin.x / UNITS_PER_BLOCK, origin.y / UNITS_PER_BLOCK, origin.z / UNITS_PER_BLOCK);
matrices.mulPose(Axis.XP.rotationDegrees(rotation.x));
matrices.mulPose(Axis.YP.rotationDegrees(rotation.y));
matrices.mulPose(Axis.ZP.rotationDegrees(rotation.z));
matrices.translate(-origin.x / UNITS_PER_BLOCK, -origin.y / UNITS_PER_BLOCK, -origin.z / UNITS_PER_BLOCK);
}
float inflate = cube.getInflate();
float x1 = (cube.getFrom().x - inflate) / UNITS_PER_BLOCK;
float y1 = (cube.getFrom().y - inflate) / UNITS_PER_BLOCK;
float z1 = (cube.getFrom().z - inflate) / UNITS_PER_BLOCK;
float x2 = (cube.getTo().x + inflate) / UNITS_PER_BLOCK;
float y2 = (cube.getTo().y + inflate) / UNITS_PER_BLOCK;
float z2 = (cube.getTo().z + inflate) / UNITS_PER_BLOCK;
Matrix4f m = new Matrix4f(matrices.last().pose());
CosmeticCube.CubeFace[] faces = cube.getFaces();
int tw = cube.getTextureWidth();
int th = cube.getTextureHeight();
for (int side = 0; side < 6; side++) {
CosmeticCube.CubeFace face = faces[side];
if (face == null) continue;
addFace(m, face, tw, th, x1, y1, z1, x2, y2, z2, side, out);
}
matrices.popPose();
}
private static void addFace(Matrix4f m, CosmeticCube.CubeFace face, float tw, float th,
float x1, float y1, float z1, float x2, float y2, float z2,
int side, Map<Integer, List<PendingFace>> out) {
PendingFace quad = switch (side) {
case 0 -> quad(m,
x2, y2, z1, x1, y2, z1, x1, y1, z1, x2, y1, z1);
case 1 -> quad(m,
x2, y2, z2, x2, y2, z1, x2, y1, z1, x2, y1, z2);
case 2 -> quad(m,
x1, y2, z2, x2, y2, z2, x2, y1, z2, x1, y1, z2);
case 3 -> quad(m,
x1, y2, z1, x1, y2, z2, x1, y1, z2, x1, y1, z1);
case 4 -> quad(m,
x1, y2, z2, x2, y2, z2, x2, y2, z1, x1, y2, z1);
case 5 -> quad(m,
x1, y1, z1, x2, y1, z1, x2, y1, z2, x1, y1, z2);
default -> null;
};
if (quad == null) return;
float u1 = face.getU1() / tw;
float v1 = face.getV1() / th;
float u2 = face.getU2() / tw;
float v2 = face.getV2() / th;
quad.us[0] = u1;
quad.vs[0] = v1;
quad.us[1] = u2;
quad.vs[1] = v1;
quad.us[2] = u2;
quad.vs[2] = v2;
quad.us[3] = u1;
quad.vs[3] = v2;
out.computeIfAbsent(face.getTextureIndex(), k -> new ArrayList<>()).add(quad);
}
private static PendingFace quad(Matrix4f m,
float x1, float y1, float z1,
float x2, float y2, float z2,
float x3, float y3, float z3,
float x4, float y4, float z4) {
PendingFace face = new PendingFace();
Vector3f p1 = m.transformPosition(new Vector3f(x1, y1, z1));
Vector3f p2 = m.transformPosition(new Vector3f(x2, y2, z2));
Vector3f p3 = m.transformPosition(new Vector3f(x3, y3, z3));
Vector3f p4 = m.transformPosition(new Vector3f(x4, y4, z4));
face.xs = new float[]{p1.x, p2.x, p3.x, p4.x};
face.ys = new float[]{p1.y, p2.y, p3.y, p4.y};
face.zs = new float[]{p1.z, p2.z, p3.z, p4.z};
return face;
}
private static final class PendingFace {
float[] xs;
float[] ys;
float[] zs;
final float[] us = new float[4];
final float[] vs = new float[4];
}
}

View file

@ -0,0 +1,97 @@
package dev.loki.lovisual.features.cosmetic;
import dev.loki.lovisual.config.ConfigPaths;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* Scans {@code config/lovisual/cosmetics/&lt;type&gt;/&lt;name&gt;} (and the flat root as a
* fallback) for user-provided cosmetic folders (avatar.json + .bbmodel) and keeps the
* loaded catalog in memory.
*/
public final class CosmeticRepository {
private static final CosmeticRepository INSTANCE = new CosmeticRepository();
private final Map<String, CosmeticModel> catalog = new LinkedHashMap<>();
public static CosmeticRepository get() {
return INSTANCE;
}
public Path root() {
return ConfigPaths.root().resolve("cosmetics");
}
public void reload() {
catalog.clear();
discoverUserFolder(root());
}
public List<CosmeticModel> all() {
return new ArrayList<>(catalog.values());
}
public List<CosmeticModel> byType(CosmeticType type) {
return catalog.values().stream().filter(m -> m.getType() == type).toList();
}
public CosmeticModel get(String id) {
return catalog.get(id);
}
private void register(CosmeticModel model) {
if (model != null && !catalog.containsKey(model.getId())) {
catalog.put(model.getId(), model);
}
}
private void discoverUserFolder(Path root) {
if (!Files.isDirectory(root)) {
try {
Files.createDirectories(root);
for (CosmeticType type : CosmeticType.values()) {
Files.createDirectories(root.resolve(type.getId()));
}
} catch (Exception ignored) {
}
return;
}
for (CosmeticType type : CosmeticType.values()) {
Path typeDir = root.resolve(type.getId());
scanDirectory(typeDir, type);
}
scanDirectory(root, null);
}
private void scanDirectory(Path dir, CosmeticType forcedType) {
if (!Files.isDirectory(dir)) return;
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(Files::isDirectory).forEach(sub -> {
File folder = sub.toFile();
if (new File(folder, "avatar.json").exists() || hasBbmodel(folder)) {
CosmeticModel model = CosmeticModel.loadExternal(folder, "user");
if (model != null) {
if (forcedType != null && model.getType() == CosmeticType.MODEL) {
model = model.withType(forcedType);
}
register(model);
}
}
});
} catch (Exception ignored) {
}
}
private static boolean hasBbmodel(File folder) {
return new File(folder, "model.bbmodel").exists()
|| new File(folder, folder.getName() + ".bbmodel").exists();
}
}

View file

@ -0,0 +1,119 @@
package dev.loki.lovisual.features.cosmetic;
import com.google.gson.JsonObject;
import com.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
public final class CosmeticTextureSlot {
private final int index;
private final Identifier id;
private int width;
private int height;
private final String fileName;
private boolean loaded;
private JsonObject textureJson;
CosmeticTextureSlot(int index, Identifier id, int width, int height, String fileName) {
this.index = index;
this.id = id;
this.width = Math.max(width, 1);
this.height = Math.max(height, 1);
this.fileName = fileName;
}
public int getIndex() {
return index;
}
public Identifier getId() {
return id;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public boolean isLoaded() {
return loaded;
}
void markLoaded() {
loaded = true;
}
static CosmeticTextureSlot fromJson(int index, String modelKey, JsonObject texture) {
int w = texture.has("width") ? texture.get("width").getAsInt() : 64;
int h = texture.has("height") ? texture.get("height").getAsInt() : 64;
String name = texture.has("name") ? texture.get("name").getAsString() : "tex" + index;
Identifier id = Identifier.fromNamespaceAndPath(
"lovisual",
"dynamic/cosmetic/" + modelKey + "/tex/" + index
);
CosmeticTextureSlot slot = new CosmeticTextureSlot(index, id, w, h, name);
slot.textureJson = texture;
return slot;
}
void reset() {
loaded = false;
}
/**
* Attempts to load this texture slot from, in order: the cosmetic's external folder
* (file name, then outfits/&lt;file&gt;, then a generic texture.png fallback), or an
* inline base64 "source" embedded in the .bbmodel JSON.
*/
void load(Minecraft mc, File externalDir, String resourcePath) {
if (loaded || mc == null || mc.getTextureManager() == null) return;
try {
if (externalDir != null) {
if (tryFile(mc, new File(externalDir, fileName))) return;
if (tryFile(mc, new File(externalDir, "outfits/" + fileName))) return;
if (tryFile(mc, new File(externalDir, "texture.png"))) return;
}
if (textureJson != null && textureJson.has("source")) {
String source = textureJson.get("source").getAsString();
if (source.startsWith("data:image") && source.contains(",")) {
byte[] bytes = Base64.getDecoder().decode(source.substring(source.indexOf(',') + 1));
try (InputStream is = new ByteArrayInputStream(bytes)) {
register(mc, is);
}
}
}
} catch (Exception ignored) {
}
}
private boolean tryFile(Minecraft mc, File file) throws IOException {
if (!file.exists()) return false;
try (InputStream is = new FileInputStream(file)) {
register(mc, is);
return true;
}
}
private void register(Minecraft mc, InputStream is) throws IOException {
NativeImage image = NativeImage.read(is);
width = Math.max(image.getWidth(), 1);
height = Math.max(image.getHeight(), 1);
DynamicTexture texture = new DynamicTexture(() -> "lovisual_cosmetic_" + index, image);
mc.getTextureManager().register(id, texture);
loaded = true;
}
}

View file

@ -0,0 +1,32 @@
package dev.loki.lovisual.features.cosmetic;
public enum CosmeticType {
MODEL("model", "Модель"),
PET("pet", "Питомец"),
ACCESSORY("accessory", "Аксессуар");
private final String id;
private final String displayName;
CosmeticType(String id, String displayName) {
this.id = id;
this.displayName = displayName;
}
public String getId() {
return id;
}
public String getDisplayName() {
return displayName;
}
public static CosmeticType fromJson(String raw) {
if (raw == null || raw.isBlank()) return MODEL;
return switch (raw.toLowerCase()) {
case "pet", "pets", "питомец", "питомцы" -> PET;
case "accessory", "accessories", "item", "items", "аксессуар", "аксессуары" -> ACCESSORY;
default -> MODEL;
};
}
}

View file

@ -63,7 +63,11 @@ final class ClickGuiInputHandler {
static void onEditorMouseButtonScaled(double x, double y, int button, boolean pressed) {
updateMouse(x, y, true);
if (ClickGuiRenderer.waitingForKey) {
if (pressed) ClickGuiKeyBindManager.addPendingMouseButton(button);
if (pressed) {
ClickGuiKeyBindManager.addPendingMouseButton(button);
} else {
ClickGuiKeyBindManager.onMouseReleased(button);
}
return;
}
if (ClickGuiRenderer.textEditor != null) {
@ -96,7 +100,11 @@ final class ClickGuiInputHandler {
private static void handleMouseButton(int button, boolean pressed) {
if (!ClickGuiLifecycle.isInputReady()) return;
if (ClickGuiRenderer.waitingForKey) {
if (pressed) ClickGuiKeyBindManager.addPendingMouseButton(button);
if (pressed) {
ClickGuiKeyBindManager.addPendingMouseButton(button);
} else {
ClickGuiKeyBindManager.onMouseReleased(button);
}
return;
}

View file

@ -6,6 +6,7 @@ import dev.loki.lovisual.util.input.KeyUtil;
import dev.loki.lovisual.util.wav.ClickGuiSounds;
import org.lwjgl.glfw.GLFW;
import java.util.LinkedHashSet;
import java.util.Set;
final class ClickGuiKeyBindManager {
@ -13,11 +14,18 @@ final class ClickGuiKeyBindManager {
private ClickGuiKeyBindManager() {
}
// Keys/buttons currently physically held down while capturing a bind.
// A single tap (press+release with nothing else pressed meanwhile) commits
// instantly on release; holding it while pressing another key builds a combo,
// which commits once every held key in it has been released.
private static final Set<Integer> heldKeys = new LinkedHashSet<>();
static void beginKeyBind(Object owner) {
ClickGuiEditorManager.clearInlineText();
ClickGuiRenderer.waitingForKey = true;
ClickGuiRenderer.keyBindOwner = owner;
ClickGuiRenderer.pendingBindKeys.clear();
heldKeys.clear();
ClickGuiSearch.deactivate();
ClickGuiSounds.bindingStart();
}
@ -26,17 +34,51 @@ final class ClickGuiKeyBindManager {
ClickGuiRenderer.waitingForKey = false;
ClickGuiRenderer.keyBindOwner = null;
ClickGuiRenderer.pendingBindKeys.clear();
heldKeys.clear();
}
/** Cancels bind capture without touching the previously saved value. */
static void cancelKeyBind() {
if (!ClickGuiRenderer.waitingForKey) return;
ClickGuiRenderer.waitingForKey = false;
ClickGuiRenderer.keyBindOwner = null;
ClickGuiRenderer.pendingBindKeys.clear();
heldKeys.clear();
ClickGuiSounds.bindingStart();
}
static void addPendingKey(int keyCode, int scancode) {
int code = KeyUtil.fromKeyInput(keyCode, scancode);
if (code == KeyUtil.NONE) return;
ClickGuiRenderer.pendingBindKeys.add(code);
heldKeys.add(code);
}
static void addPendingMouseButton(int button) {
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
ClickGuiRenderer.pendingBindKeys.add(KeyUtil.mouseButtonToCode(button));
int code = KeyUtil.mouseButtonToCode(button);
ClickGuiRenderer.pendingBindKeys.add(code);
heldKeys.add(code);
}
static void onKeyReleased(int keyCode, int scancode) {
handleRelease(KeyUtil.fromKeyInput(keyCode, scancode));
}
static void onMouseReleased(int button) {
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
handleRelease(KeyUtil.mouseButtonToCode(button));
}
private static void handleRelease(int code) {
if (!ClickGuiRenderer.waitingForKey) return;
if (code == KeyUtil.NONE) return;
if (!heldKeys.remove(code)) return;
if (!heldKeys.isEmpty()) return;
if (ClickGuiRenderer.pendingBindKeys.isEmpty()) return;
submitKeySelection(ClickGuiRenderer.pendingBindKeys);
finishKeyBind();
}
static void submitKeySelection(Set<Integer> keys) {

View file

@ -122,8 +122,9 @@ final class ClickGuiKeyboardHandler {
if (ClickGuiRenderer.waitingForKey) {
if (key == GLFW.GLFW_KEY_ESCAPE) {
ClickGuiKeyBindManager.submitKeySelection((String) null);
ClickGuiKeyBindManager.finishKeyBind();
// Escape always exits the bind capture immediately without
// touching the previously saved bind.
ClickGuiKeyBindManager.cancelKeyBind();
return true;
}
@ -135,6 +136,11 @@ final class ClickGuiKeyboardHandler {
}
ClickGuiKeyBindManager.addPendingKey(key, scancode);
} else if (action == GLFW.GLFW_RELEASE) {
// A tap (press+release with no other key held) commits instantly;
// holding this key while pressing another builds a combo, which
// commits once every held key in it has been released.
ClickGuiKeyBindManager.onKeyReleased(key, scancode);
}
return true;

View file

@ -53,6 +53,9 @@ import java.util.Map;
@HudElementRegister(order = 20)
public final class BetterTooltips extends AbstractHudElement {
private static final String[] TOOLTIP_FONT_OPTIONS = {
"Onest", "OnestMedium", "OnestLight", "Inter", "InterMedium", "Comfortaa", "Iosevka", "ProFont", "Monsterrat"
};
public static final BetterTooltips INSTANCE = new BetterTooltips();
public static final String GROUP_TOOLTIP_GUI = "vanilla_tooltip_gui";
public static final String GROUP_TOOLTIP_ITEMS = "vanilla_tooltip_items";
@ -95,6 +98,9 @@ public final class BetterTooltips extends AbstractHudElement {
private final BooleanValue itemTooltipEnabled = bool("item_tooltip", true);
private final BooleanValue shulkerPreviewEnabled = bool("shulker_preview", true);
private final BooleanValue itemInfoColorize = bool("item_info_colorize", true);
private final ModeValue tooltipFont = mode("tooltip_font", "Iosevka", TOOLTIP_FONT_OPTIONS);
private final NumberValue<Float> tooltipTextSize =
num("tooltip_text_size", 1.0f, 0.5f, 2.0f);
private final ItemIdSetValue topIgnore = registerTopIgnore();
private final NumberValue<Float> shulkerPreviewSlotSize =
visibleWhen(num("shulker_slot_size", 18.0f, 12.0f, 24.0f), this::isShulkerPreviewEnabled);
@ -197,14 +203,14 @@ public final class BetterTooltips extends AbstractHudElement {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
TextRenderer tr = Fonts.renderer("Iosevka", FontInfo.Type.Regular, TextRenderer.get());
TextRenderer tr = Fonts.renderer(INSTANCE.tooltipFont.get(), FontInfo.Type.Regular, TextRenderer.get());
if (tr == null) return;
PreviewLayout preview = buildShulkerPreviewLayout();
boolean previewOpen = preview != null;
List<TooltipLine> finalLines = prepareTooltipLines(lines, previewOpen);
float textScale = TOOLTIP_TEXT_SCALE * TOOLTIP_SCALE_OVERRIDE;
float textScale = TOOLTIP_TEXT_SCALE * TOOLTIP_SCALE_OVERRIDE * INSTANCE.tooltipTextSize.get();
tr.begin(textScale, false, false);
int maxWidth = 0;
for (TooltipLine line : finalLines) {

View file

@ -28,6 +28,8 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
@HudElementRegister(order = 40)
public final class CustomBar extends AbstractHudElement {
static final String COLOR_THEME = "Theme";
static final String COLOR_CUSTOM = "Custom";
public static final CustomBar INSTANCE = new CustomBar();
static final float XP_BAR_WIDTH = 182f;
static final double XP_TEXT_SCALE = 0.59;
@ -77,8 +79,6 @@ public final class CustomBar extends AbstractHudElement {
static final float LOCATOR_LABEL_CHIP_RADIUS = 3.6f;
static final float LOCATOR_LABEL_ACCENT_W = 1.15f;
static final int LOCATOR_LABEL_MAX_ROWS = 4;
static final String COLOR_THEME = "Theme";
static final String COLOR_CUSTOM = "Custom";
private final NumberValue<Integer> overlayAlpha =
new NumberValue<>("overlay_alpha", 255, 30, 255);

View file

@ -39,8 +39,10 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
@HudElementRegister(order = 50)
public final class CustomHealthBar extends AbstractHudElement {
public static final CustomHealthBar INSTANCE = new CustomHealthBar();
private static final String LAYOUT_ID = "lovisual:api/hud/static/custom_health_bar";
private static final String STYLE_TARGET_HUD = "TargetHud";
private static final String STYLE_DYNAMIC = "Dynamic";
public static final CustomHealthBar INSTANCE = new CustomHealthBar();
private static final float HEALTH_BAR_WIDTH = 81f;
private static final float HEALTH_BAR_HEIGHT = 9f;
private static final float HEALTH_BAR_RADIUS = 5.0f;
@ -50,8 +52,6 @@ public final class CustomHealthBar extends AbstractHudElement {
private static final int HEALTH_BAR_X_OFFSET = 0;
private static final int HEALTH_BAR_Y_OFFSET = 0;
private static final float HEALTH_ANIM_SPEED = 10.0f;
private static final String STYLE_TARGET_HUD = "TargetHud";
private static final String STYLE_DYNAMIC = "Dynamic";
private static float animatedHealth = -1.0f;
private static float animatedAbsorb = -1.0f;
private static long lastHealthAnimMs = -1L;

View file

@ -33,6 +33,11 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
@HudElementRegister(order = 30)
public final class CustomHotbar extends AbstractHudElement {
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 COLOR_THEME = "Theme";
private static final String COLOR_CUSTOM = "Custom";
public static final CustomHotbar INSTANCE = new CustomHotbar();
private static final int BG_ALPHA = 0xAA;
private static final float RADIUS = 6.0f;
@ -49,11 +54,6 @@ public final class CustomHotbar extends AbstractHudElement {
private static final float SELECT_SLOT_STEP = SLOT_STEP;
private static final float SELECT_ANIMATION_MS = 145.0f;
private static final float SELECT_ANIMATION_MIN_PROGRESS = 0.015f;
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 COLOR_THEME = "Theme";
private static final String COLOR_CUSTOM = "Custom";
private static int animatedSelectedSlot = -1;
private static float selectionStartSlot = 0.0f;
private static float selectionTargetSlot = 0.0f;

View file

@ -69,9 +69,8 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
@HudElementRegister(order = 60)
public final class DynamicIsland extends AbstractHudElement {
public static final DynamicIsland INSTANCE = new DynamicIsland();
private static final String LAYOUT_ID = "lovisual:api/hud/static/dynamic_island";
public static final DynamicIsland INSTANCE = new DynamicIsland();
private static final float TOP_Y = 18f;
private static final float CLOSED_HEIGHT = 35f;

View file

@ -1,258 +0,0 @@
/*
* 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.module.modules.combat;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.*;
import dev.loki.lovisual.config.common.CommonSettingSchemas;
import dev.loki.lovisual.config.values.EnumValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.util.combat.VulcanReachController;
import java.util.List;
import java.util.Optional;
//todo Description
@ModuleInfo(
id = "reach",
displayName = "Reach",
category = ModuleCategory.COMBAT
)
public class Reach extends Module {
public enum RaycastMode {
STRICT,
THROUGH_WALLS,
NONE
}
public final EnumValue<RaycastMode> raycast =
enumCommon(
"reachContext",
"raycast",
CommonSettingSchemas.COMBAT_RAYCAST,
RaycastMode.STRICT,
RaycastMode.values()
);
private final Minecraft mc = Minecraft.getInstance();
private final EnumValue<Mode> mode =
enumCommon(
"reachMode",
"mode",
CommonSettingSchemas.ANTICHEAT_MODE,
Mode.NORMAL,
Mode.values()
);
private final NumberValue<Double> distance =
numCommon(
"reachDistance",
"range",
CommonSettingSchemas.COMBAT_RANGE,
4.5,
3.0,
6.0
);
private final NumberValue<Double> wallDistance =
visibleWhen(numCommon(
"reachWallDistance",
"wall_range",
CommonSettingSchemas.COMBAT_WALL_RANGE,
3.0,
0.0,
6.0
), () -> raycast.get() == RaycastMode.THROUGH_WALLS);
private static double boxDistanceSq(AABB box, Vec3 p) {
double dx = 0.0;
if (p.x < box.minX) dx = box.minX - p.x;
else if (p.x > box.maxX) dx = p.x - box.maxX;
double dy = 0.0;
if (p.y < box.minY) dy = box.minY - p.y;
else if (p.y > box.maxY) dy = p.y - box.maxY;
double dz = 0.0;
if (p.z < box.minZ) dz = box.minZ - p.z;
else if (p.z > box.maxZ) dz = p.z - box.maxZ;
return dx * dx + dy * dy + dz * dz;
}
public EntityHitResult raycastEntities(LocalPlayer player) {
if (!isEnabled()) return null;
if (player == null || mc.level == null) return null;
double visibleRange = distance.get();
double throughWallsRange = raycast.get() == RaycastMode.THROUGH_WALLS
? wallDistance.get()
: 0.0;
double searchRange = Math.max(visibleRange, throughWallsRange);
Vec3 start = player.getEyePosition(1.0F);
Vec3 direction = player.getViewVector(1.0F).normalize();
Vec3 end = start.add(direction.scale(searchRange));
double blockLimitSq = resolveBlockLimitSq(player, start, end, visibleRange, throughWallsRange);
AABB search = new AABB(start, end).inflate(1.0);
List<Entity> candidates = mc.level.getEntities(
player,
search,
e -> e instanceof LivingEntity && e.isAlive()
);
return findBestHit(player, start, end, candidates, blockLimitSq, searchRange);
}
public EntityHitResult raycastEntities(LocalPlayer player, Vec3 start, Vec3 direction) {
if (!isEnabled()) return null;
if (player == null || mc.level == null) return null;
double visibleRange = distance.get();
double throughWallsRange = raycast.get() == RaycastMode.THROUGH_WALLS
? wallDistance.get()
: 0.0;
double searchRange = Math.max(visibleRange, throughWallsRange);
Vec3 dir = direction.normalize();
Vec3 end = start.add(dir.scale(searchRange));
double blockLimitSq = resolveBlockLimitSq(player, start, end, visibleRange, throughWallsRange);
AABB search = new AABB(start, end).inflate(1.0);
List<Entity> candidates = mc.level.getEntities(
player,
search,
e -> e instanceof LivingEntity && e.isAlive()
);
return findBestHit(player, start, end, candidates, blockLimitSq, searchRange);
}
private EntityHitResult findBestHit(LocalPlayer player,
Vec3 start,
Vec3 end,
List<Entity> candidates,
double blockLimitSq,
double requestedReach) {
EntityHitResult bestHit = null;
double bestBoxDistSq = Double.MAX_VALUE;
double bestHitDistSq = Double.MAX_VALUE;
for (Entity e : candidates) {
AABB baseBox = e.getBoundingBox();
AABB bb = baseBox;
Optional<Vec3> hit = bb.contains(start)
? Optional.of(start)
: bb.clip(start, end);
if (hit.isEmpty()) continue;
double boxDistSq = boxDistanceSq(baseBox, start);
double entityReach = resolveEntityReach(e, requestedReach);
double entityReachSq = entityReach * entityReach;
if (boxDistSq > entityReachSq) continue;
if (boxDistSq > blockLimitSq) continue;
double hitDistSq = start.distanceToSqr(hit.get());
if (boxDistSq < bestBoxDistSq
|| (boxDistSq == bestBoxDistSq && hitDistSq < bestHitDistSq)) {
bestBoxDistSq = boxDistSq;
bestHitDistSq = hitDistSq;
bestHit = new EntityHitResult(e, hit.get());
}
}
return bestHit;
}
private double resolveEntityReach(Entity entity, double requestedReach) {
if (mode.get() == Mode.VULCAN_297 && entity instanceof Player) {
return VulcanReachController.INSTANCE.clampReach(entity, requestedReach);
}
return requestedReach;
}
public boolean isVulcan297Mode() {
return mode.get() == Mode.VULCAN_297;
}
public double getConfiguredDistance() {
return distance.get();
}
private double resolveBlockLimitSq(LocalPlayer player,
Vec3 start,
Vec3 end,
double visibleRange,
double throughWallsRange) {
RaycastMode mode = raycast.get();
if (mode == RaycastMode.NONE) {
double maxRange = Math.max(visibleRange, throughWallsRange);
return maxRange * maxRange;
}
if (mode == RaycastMode.THROUGH_WALLS) {
double maxAllowed = Math.max(visibleRange, throughWallsRange);
HitResult blockHit = mc.level.clip(new ClipContext(
start,
end,
ClipContext.Block.OUTLINE,
ClipContext.Fluid.NONE,
player
));
if (!(blockHit instanceof BlockHitResult bhr)) {
return maxAllowed * maxAllowed;
}
double blockDistSq = bhr.getLocation().distanceToSqr(start);
if (throughWallsRange <= 0.0) {
return blockDistSq;
}
return Math.max(blockDistSq, throughWallsRange * throughWallsRange);
}
HitResult blockHit = mc.level.clip(new ClipContext(
start,
end,
ClipContext.Block.OUTLINE,
ClipContext.Fluid.NONE,
player
));
if (blockHit instanceof BlockHitResult bhr) {
return bhr.getLocation().distanceToSqr(start);
}
return visibleRange * visibleRange;
}
public enum Mode {
NORMAL,
VULCAN_297
}
}

View file

@ -68,6 +68,7 @@ public class TargetESP extends Module {
private static final String SETTING_BLACKHOLE_SIZE = "blackhole_size";
private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed";
private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style";
private static final String SETTING_VORTEX_SPEED = "vortex_speed";
private static final Identifier GHOST_PARTICLE_TEXTURE = TextureStorage.FIRE_FLY;
private static final Identifier CAPTURE_MARK_TEXTURE = TextureStorage.CAPTURE;
private static final Identifier CRYSTAL_BLOOM = TextureStorage.BLOOM;
@ -87,7 +88,7 @@ public class TargetESP extends Module {
"targetEspMode",
SETTING_MODE,
"ghosts",
"ghosts", "ring", "capture_mark", "crystals", "blackhole"
"ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex"
);
private final RGBAColorValue colorValue = color("targetEspColor", SETTING_COLOR, "#FFFF5555");
private final ModeValue colorMode = visibleWhen(modeSetting(
@ -130,6 +131,8 @@ public class TargetESP extends Module {
visibleWhen(num("targetEspBlackholeSpinSpeed", SETTING_BLACKHOLE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isBlackholeMode);
private final ModeValue blackholeStyle =
visibleWhen(modeSetting("targetEspBlackholeStyle", SETTING_BLACKHOLE_STYLE, "Single", "Single", "Body"), this::isBlackholeMode);
private final NumberValue<Float> vortexSpeed =
visibleWhen(num("targetEspVortexSpeed", SETTING_VORTEX_SPEED, 1.2f, 0.2f, 4.0f), this::isVortexMode);
private final List<CrystalInstance> crystalList = new ArrayList<>();
private LivingEntity target;
private LivingEntity renderTarget;
@ -237,6 +240,75 @@ public class TargetESP extends Module {
}
}
private static final int VORTEX_HELIX_STEPS = 28;
private static final int VORTEX_RING_SEGMENTS = 24;
private static void renderVortex(Renderer3D renderer,
LivingEntity target,
float tickDelta,
IntFunction<Integer> palette,
float spinSpeed) {
if (renderer == null || target == null) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
float radius = target.getBbWidth() * 0.7f;
float height = target.getBbHeight();
double time = (GameClock.millis() / 1000.0) * spinSpeed;
float prevWidth = RenderState.lineWidth;
RenderState.lineWidth = 1.4f;
MeshBuilder mesh;
try {
mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
} finally {
RenderState.lineWidth = prevWidth;
}
if (mesh == null) return;
for (int strand = 0; strand < 2; strand++) {
double strandOffset = strand * Math.PI;
int prevIndex = -1;
for (int i = 0; i <= VORTEX_HELIX_STEPS; i++) {
double t = (double) i / VORTEX_HELIX_STEPS;
double angle = time * 2.4 + t * Math.PI * 3.0 + strandOffset;
double r = radius * (0.85 + 0.15 * Math.sin(t * Math.PI));
double x = pos.x + Math.cos(angle) * r;
double y = pos.y + t * height;
double z = pos.z + Math.sin(angle) * r;
int color = applyOpacity(palette.apply((int) (t * 180)), (float) (1.0 - t * 0.35));
int index = addVertex(mesh, x, y, z, color);
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
float[] ringHeights = {0.05f, height * 0.5f, height * 0.95f};
float[] ringSpeeds = {0.4f, -0.55f, 0.35f};
for (int ring = 0; ring < ringHeights.length; ring++) {
double ringY = pos.y + ringHeights[ring];
double ringRotation = time * ringSpeeds[ring];
int ringColor = applyOpacity(palette.apply(60), 0.4f);
int prevIndex = -1;
int firstIndex = -1;
for (int i = 0; i <= VORTEX_RING_SEGMENTS; i++) {
double angle = ringRotation + (i % VORTEX_RING_SEGMENTS) * 2.0 * Math.PI / VORTEX_RING_SEGMENTS;
double x = pos.x + Math.cos(angle) * radius;
double z = pos.z + Math.sin(angle) * radius;
int index = addVertex(mesh, x, ringY, z, ringColor);
if (firstIndex < 0) {
firstIndex = index;
}
if (prevIndex >= 0) {
mesh.line(prevIndex, index);
}
prevIndex = index;
}
}
}
private static void renderCaptureMark(Renderer3D renderer,
Entity target,
float tickDelta,
@ -730,6 +802,8 @@ public class TargetESP extends Module {
} else if ("blackhole".equals(mode)) {
renderBlackhole(renderer, renderTarget, tickDelta, palette,
blackholeSize.get(), blackholeSpinSpeed.get(), targetAlpha, blackholeStyle.get());
} else if ("vortex".equals(mode)) {
renderVortex(renderer, renderTarget, tickDelta, palette, vortexSpeed.get());
}
}
@ -900,6 +974,10 @@ public class TargetESP extends Module {
return "blackhole".equals(espMode.get());
}
private boolean isVortexMode() {
return "vortex".equals(espMode.get());
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode());
}

View file

@ -0,0 +1,276 @@
package dev.loki.lovisual.features.module.modules.visuals;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.phys.AABB;
import dev.loki.lovisual.config.values.EnumValue;
import dev.loki.lovisual.config.values.ItemIdSetValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.config.values.RGBAColorValue;
import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.Timer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
//todo Description
@ModuleInfo(
id = "blockesp",
displayName = "BlockESP",
category = ModuleCategory.VISUALS,
description = "Подсвечивает выбранные блоки сквозь стены"
)
public final class BlockESP extends Module {
private final Minecraft mc = Minecraft.getInstance();
private final ItemIdSetValue blockList =
itemList("block_list", TextListSetting.PickerMode.BLOCKS);
private final NumberValue<Integer> range =
num("range", 64, 8, 160);
private final NumberValue<Float> scanInterval =
num("scan_interval", 2.0f, 0.5f, 10.0f);
private final RGBAColorValue fillColor =
color("fill_color", "#4000FFC8");
private final RGBAColorValue fillColor2 =
visibleWhen(color("fill_color2", "#40FFAA00"), this::usesSecondaryColor);
private final EnumValue<ColorMode> colorMode =
enumMode("color_mode", ColorMode.PER_BLOCK, ColorMode.values());
private final NumberValue<Integer> colorSpeed =
visibleWhen(num("color_speed", 18, 2, 54), () -> colorMode.get() != ColorMode.PER_BLOCK && colorMode.get() != ColorMode.STATIC);
private final Timer scanTimer = new Timer();
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "BlockESP-Scanner");
thread.setDaemon(true);
return thread;
});
private final AtomicBoolean scanning = new AtomicBoolean(false);
private final AtomicBoolean scanInProgress = new AtomicBoolean(false);
private volatile List<BlockPos> foundBlocks = new ArrayList<>();
public BlockESP() {
}
@Override
public void onEnable() {
scanTimer.setMs(0);
scanning.set(true);
}
@Override
public void onDisable() {
scanning.set(false);
foundBlocks = new ArrayList<>();
}
@Override
public void onTick() {
if (mc.level == null || mc.player == null) return;
if (scanTimer.passedS(scanInterval.get())) {
scanTimer.reset();
requestScan();
}
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.BEFORE_TRANSLUCENT;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || mc.level == null || mc.player == null) return;
if (blockList.get().isEmpty()) return;
List<BlockPos> snapshot = foundBlocks;
for (BlockPos pos : snapshot) {
BlockState state = mc.level.getBlockState(pos);
if (state.isAir()) continue;
if (!blockList.get().contains(blockId(state.getBlock()))) continue;
int argb = resolveColor(state.getBlock(), pos);
addFilledBox(renderer, new AABB(pos), argb);
}
}
private void requestScan() {
if (mc.level == null || mc.player == null) return;
if (!scanning.get()) return;
if (executor.isShutdown()) return;
if (!scanInProgress.compareAndSet(false, true)) return;
executor.submit(() -> {
try {
scan();
} finally {
scanInProgress.set(false);
}
});
}
private void scan() {
if (mc.level == null || mc.player == null) return;
List<BlockPos> result = new ArrayList<>();
BlockPos center = mc.player.blockPosition();
int r = range.get();
int minX = center.getX() - r;
int maxX = center.getX() + r;
int minY = Math.max(mc.level.getMinY(), center.getY() - r);
int maxY = Math.min(mc.level.getMaxY(), center.getY() + r);
int minZ = center.getZ() - r;
int maxZ = center.getZ() + r;
int minChunkX = minX >> 4;
int maxChunkX = maxX >> 4;
int minChunkZ = minZ >> 4;
int maxChunkZ = maxZ >> 4;
for (int cx = minChunkX; cx <= maxChunkX; cx++) {
for (int cz = minChunkZ; cz <= maxChunkZ; cz++) {
if (!scanning.get()) return;
if (!mc.level.hasChunk(cx, cz)) continue;
int chunkMinX = Math.max(minX, cx << 4);
int chunkMaxX = Math.min(maxX, (cx << 4) + 15);
int chunkMinZ = Math.max(minZ, cz << 4);
int chunkMaxZ = Math.min(maxZ, (cz << 4) + 15);
for (int x = chunkMinX; x <= chunkMaxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = chunkMinZ; z <= chunkMaxZ; z++) {
if (!scanning.get()) return;
BlockPos pos = new BlockPos(x, y, z);
BlockState state = mc.level.getBlockState(pos);
if (state.isAir()) continue;
if (blockList.get().contains(blockId(state.getBlock()))) {
result.add(pos.immutable());
}
}
}
}
}
}
foundBlocks = result;
}
private String blockId(Block block) {
return BuiltInRegistries.BLOCK.getKey(block).toString();
}
private int resolveColor(Block block, BlockPos pos) {
if (colorMode.get() == ColorMode.PER_BLOCK) {
MapColor mapColor = block.defaultMapColor();
return withAlpha(mapColor.col, alpha(fillColor.getArgb()));
}
return AnimatedRenderColors.resolve(
animatedColorMode(),
colorSpeed.get(),
spatialIndex(pos),
fillColor.getArgb(),
fillColor2.getArgb(),
true
);
}
private AnimatedRenderColors.Mode animatedColorMode() {
return switch (colorMode.get()) {
case PER_BLOCK, STATIC -> AnimatedRenderColors.Mode.STATIC;
case RAINBOW -> AnimatedRenderColors.Mode.RAINBOW;
case FADE -> AnimatedRenderColors.Mode.FADE;
};
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode());
}
private int spatialIndex(BlockPos pos) {
return Math.floorMod(pos.getX() * 13 + pos.getY() * 7 + pos.getZ() * 17, 3600);
}
private static void addFilledBox(Renderer3D renderer, AABB box, int argb) {
int a = alpha(argb);
int r = red(argb);
int g = green(argb);
int b = blue(argb);
double minX = box.minX;
double minY = box.minY;
double minZ = box.minZ;
double maxX = box.maxX;
double maxY = box.maxY;
double maxZ = box.maxZ;
renderer.quad(minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
renderer.quad(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ, r, g, b, a);
renderer.quad(minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ, r, g, b, a);
renderer.quad(maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ, r, g, b, a);
renderer.quad(minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
}
private static int withAlpha(int argb, int alpha) {
return (alpha << 24) | (argb & 0x00FFFFFF);
}
private static int red(int argb) {
return (argb >>> 16) & 255;
}
private static int green(int argb) {
return (argb >>> 8) & 255;
}
private static int blue(int argb) {
return argb & 255;
}
private static int alpha(int argb) {
return (argb >>> 24) & 255;
}
public enum ColorMode implements EnumValue.IdProvider {
PER_BLOCK("per_block"),
STATIC("static"),
RAINBOW("rainbow"),
FADE("fade");
private final String id;
ColorMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
}

View file

@ -0,0 +1,77 @@
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.features.cosmetic.CosmeticManager;
import dev.loki.lovisual.features.cosmetic.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.CosmeticRenderer;
import dev.loki.lovisual.features.cosmetic.CosmeticRepository;
import dev.loki.lovisual.features.cosmetic.CosmeticType;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
import java.util.List;
/**
* Renders the locally equipped cosmetic (model/pet/accessory) attached to the local player.
* Equip picking is a minimal next/prev keybind for now; a proper catalog screen is a follow-up.
*/
@ModuleInfo(
id = "cosmeticdisplay",
displayName = "Cosmetics",
aliases = {"cosmetics"},
category = ModuleCategory.VISUALS,
description = "Отображает надетую косметику (модель/питомец/аксессуар)"
)
public final class CosmeticDisplay extends Module {
private final Minecraft mc = Minecraft.getInstance();
{
action("Next Cosmetic", "NONE");
action("Prev Cosmetic", "NONE");
}
@Override
public void onEnable() {
CosmeticRepository.get().reload();
}
@Override
public void onTick() {
if (!isEnabled()) return;
if (isActionPressedOnce("Next Cosmetic")) {
cycle(1);
} else if (isActionPressedOnce("Prev Cosmetic")) {
cycle(-1);
}
}
private void cycle(int direction) {
List<CosmeticModel> models = CosmeticRepository.get().byType(CosmeticType.MODEL);
if (models.isEmpty()) return;
CosmeticModel current = CosmeticManager.get().getActive(CosmeticType.MODEL);
int index = current == null ? -1 : models.indexOf(current);
int next = Math.floorMod(index + direction, models.size());
CosmeticManager.get().setActive(CosmeticType.MODEL, models.get(next));
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || mc.level == null || mc.player == null) return;
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.MODEL, tickDelta);
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.PET, tickDelta);
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.ACCESSORY, tickDelta);
}
}

View file

@ -7,7 +7,6 @@
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.util.time.GameClock;
import dev.loki.lovisual.features.module.*;
import dev.loki.lovisual.features.module.Module;
@ -18,7 +17,6 @@ import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.protocol.game.ServerboundInteractPacket;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EquipmentSlot;
@ -37,14 +35,8 @@ 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.common.CommonSettingSchemas;
import dev.loki.lovisual.events.EventHandler;
import dev.loki.lovisual.events.impl.AttackEntityEvent;
import dev.loki.lovisual.events.impl.PacketEvent;
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.draggable.impl.Itemizer;
import dev.loki.lovisual.features.module.*;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.modules.combat.Reach;
import dev.loki.lovisual.features.relations.CategoryService;
import dev.loki.lovisual.features.relations.EntityFilters;
import dev.loki.lovisual.features.relations.PlayerRelations;
@ -57,10 +49,8 @@ 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.combat.VulcanReachController;
import java.util.List;
import java.util.Locale;
//todo Description
@ModuleInfo(
@ -79,9 +69,6 @@ public class Crosshair extends Module {
private static final float ORBIZ_PROGRESS_SMOOTH_SPEED = 22.0f;
private static final float COOLDOWN_LEAD = 0.06f;
private static final float RIPTIDE_CROSS_LENGTH_FACTOR = 0.65f;
private static final float VULCAN_REACH_FONT_SIZE = 14.0f;
private static final int VULCAN_REACH_PRIMARY = 0xF2FFFFFF;
private static final int VULCAN_REACH_SECONDARY = 0xD8D7DEE8;
private final Minecraft mc = Minecraft.getInstance();
private final EnumValue<CrosshairType> crosshairType =
enumSetting("crosshairType", "type", CrosshairType.CLASSIC);
@ -199,15 +186,6 @@ public class Crosshair extends Module {
num("crosshairOrbizMotionSmoothing", "orbiz_motion_smoothing", 12.0, 1.0, 30.0),
() -> isOrbiz() && isOrbizMotionDynamic()
);
private final EnumValue<VulcanReachDisplay> vulcanReachDisplay =
enumSetting("crosshairVulcanReachDisplay", "vulcan_reach_display",
VulcanReachDisplay.TIMED, VulcanReachDisplay.values());
private final NumberValue<Integer> vulcanReachLookMs =
visibleWhen(num("crosshairVulcanReachLookMs", "vulcan_reach_look_ms", 1200, 0, 3000),
() -> vulcanReachDisplay.get() == VulcanReachDisplay.TIMED);
private final NumberValue<Integer> vulcanReachAttackMs =
visibleWhen(num("crosshairVulcanReachAttackMs", "vulcan_reach_attack_ms", 2500, 0, 6000),
() -> vulcanReachDisplay.get() == VulcanReachDisplay.TIMED);
private float animatedGap;
private int animatedColor = DEFAULT_COLOR;
private float animatedOrbizProgress = 1.0f;
@ -221,9 +199,6 @@ public class Crosshair extends Module {
private boolean lastSwapCandidate;
private boolean lastChestWasElytra;
private ItemStack lastChestStack = ItemStack.EMPTY;
private long lastVulcanReachLookMs;
private long lastVulcanReachAttackMs;
private Player lastVulcanReachTarget;
private static int colorDistance(int a, int b) {
int dr = Math.abs(((a >>> 16) & 0xFF) - ((b >>> 16) & 0xFF));
@ -322,7 +297,6 @@ public class Crosshair extends Module {
renderOrbiz(renderer, centerX, centerY, scale);
float infoGapPx = (float) (orbizRadius.get() * scale);
float infoLengthPx = (float) ((orbizGlow.get() + orbizThickness.get()) * scale);
renderVulcanReachInfo(textRenderer, centerX, centerY, infoGapPx, infoLengthPx);
return;
}
@ -344,7 +318,6 @@ public class Crosshair extends Module {
double stemH = lengthPx + gapPx;
drawSegment(renderer, barX, barY, barW, thicknessPx, color, outline, outlinePx);
drawSegment(renderer, stemX, centerY, thicknessPx, stemH, color, outline, outlinePx);
renderVulcanReachInfo(textRenderer, centerX, centerY, gapPx, lengthPx);
return;
}
@ -361,35 +334,6 @@ public class Crosshair extends Module {
drawSegment(renderer, bottomX, bottomY, thicknessPx, lengthPx, color, outline, outlinePx);
drawSegment(renderer, leftX, leftY, lengthPx, thicknessPx, color, outline, outlinePx);
drawSegment(renderer, rightX, rightY, lengthPx, thicknessPx, color, outline, outlinePx);
renderVulcanReachInfo(textRenderer, centerX, centerY, gapPx, lengthPx);
}
@EventHandler
private void onAttackEntity(AttackEntityEvent event) {
if (!isVulcanReachActive()) {
return;
}
if (event.getPlayer() != mc.player || !(event.getTarget() instanceof Player player)) {
return;
}
lastVulcanReachAttackMs = GameClock.millis();
lastVulcanReachTarget = player;
}
@EventHandler
private void onPacketSendPost(PacketEvent.SendPost event) {
if (!isVulcanReachActive() || mc.level == null) {
return;
}
if (!(event.getPacket() instanceof ServerboundInteractPacket packet) || !isAttackPacket(packet)) {
return;
}
Entity target = mc.level.getEntity(packet.entityId());
if (target instanceof Player player) {
lastVulcanReachAttackMs = GameClock.millis();
lastVulcanReachTarget = player;
}
}
private float getAttackCooldownProgress() {
@ -555,85 +499,6 @@ public class Crosshair extends Module {
return resolveEntityColor(entity);
}
private void renderVulcanReachInfo(TextRenderer fallback, float centerX, float centerY, float gapPx, float lengthPx) {
if (!isVulcanReachActive()) {
return;
}
Player target = resolveVulcanReachTarget();
double requestedReach = resolveVulcanRequestedReach(target);
VulcanReachController.HudSnapshot snapshot = target != null && target.isAlive()
? VulcanReachController.INSTANCE.hudSnapshot(target, requestedReach)
: vulcanReachDisplay.get() == VulcanReachDisplay.ALWAYS
? VulcanReachController.INSTANCE.hudSnapshot(requestedReach)
: null;
if (snapshot == null) {
return;
}
String reachText = String.format(Locale.ROOT, "%.1f", snapshot.displayReach());
String hitsText = Integer.toString(snapshot.longHitsRemaining());
TextRenderer font = Fonts.renderer("Montserrat", FontInfo.Type.Regular, fallback);
float textScale = VULCAN_REACH_FONT_SIZE / 18.0f;
float x = centerX + gapPx + lengthPx + 7.0f;
float y = centerY - VULCAN_REACH_FONT_SIZE * 0.95f;
float lineHeight = VULCAN_REACH_FONT_SIZE * 0.92f;
font.begin(textScale, false, false);
font.render(reachText, x, y, new RenderColor(VULCAN_REACH_PRIMARY), true);
font.render(hitsText, x, y + lineHeight, new RenderColor(VULCAN_REACH_SECONDARY), true);
font.end();
}
private boolean isVulcanReachActive() {
Reach reach = Modules.get(Reach.class);
return reach != null && reach.isEnabled() && reach.isVulcan297Mode();
}
private double resolveVulcanRequestedReach(Player target) {
Reach reach = Modules.get(Reach.class);
if (reach != null && reach.isEnabled() && reach.isVulcan297Mode()) {
return reach.getConfiguredDistance();
}
return 3.0;
}
private Player resolveVulcanReachTarget() {
long now = GameClock.millis();
HitResult hit = mc.hitResult;
if (hit instanceof EntityHitResult entityHit
&& entityHit.getEntity() instanceof Player player
&& player.isAlive()) {
lastVulcanReachLookMs = now;
lastVulcanReachTarget = player;
return player;
}
if (lastVulcanReachTarget != null && !lastVulcanReachTarget.isAlive()) {
lastVulcanReachTarget = null;
}
if (vulcanReachDisplay.get() == VulcanReachDisplay.ALWAYS) {
return lastVulcanReachTarget;
}
if (lastVulcanReachTarget == null) {
return null;
}
long lookWindow = Math.max(0, vulcanReachLookMs.get());
long attackWindow = Math.max(0, vulcanReachAttackMs.get());
boolean recentLook = lookWindow > 0 && now - lastVulcanReachLookMs <= lookWindow;
boolean recentAttack = attackWindow > 0 && now - lastVulcanReachAttackMs <= attackWindow;
return recentLook || recentAttack ? lastVulcanReachTarget : null;
}
private boolean isAttackPacket(ServerboundInteractPacket packet) {
return packet.hand() == null && packet.location() == null;
}
private int smoothCrosshairColor(int targetColor) {
if (!colorInitialized) {
animatedColor = targetColor;
@ -780,22 +645,6 @@ public class Crosshair extends Module {
renderer.quad(x, y, w, h, color);
}
public enum VulcanReachDisplay implements EnumValue.IdProvider {
TIMED("timed"),
ALWAYS("always");
private final String id;
VulcanReachDisplay(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
private enum CrosshairType implements EnumValue.AliasProvider {
CLASSIC,
T_CLASSIC,

View file

@ -0,0 +1,61 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 pivosos2007.
*
* Portions of this file are based on, adapted from, or implemented
* with reference to a "Hold My Items" mixin distributed for another
* Minecraft client. The original attribution was not preserved.
* This implementation has since been substantially modified for LoVisual.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import net.minecraft.client.Minecraft;
/**
* Smooths and adds a subtle hand-held sway to first-person item rendering, so weapons and tools
* settle into place with an ease-out-back curve instead of vanilla's linear equip motion.
*/
@ModuleInfo(
id = "holdmyitems",
displayName = "Hold My Items",
aliases = {"hmi"},
category = ModuleCategory.VISUALS,
description = "Плавная анимация удержания предмета в руке вместо линейного ваниллового equip"
)
public final class HoldMyItems extends Module {
public final Minecraft mc = Minecraft.getInstance();
public final NumberValue<Float> swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f);
public final NumberValue<Float> easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f);
public final BooleanValue offhandToo = bool("offhand", true);
/**
* Ease-out-back: overshoots slightly past 1.0 before settling, giving the equip motion weight.
* Ported from the original "Hold My Items" easing curve, generalized with a configurable strength.
*/
public float easeOutBack(float x) {
float c1 = easeStrength.get();
float c3 = c1 + 1.0f;
float t = x - 1.0f;
return 1.0f + c3 * t * t * t + c1 * t * t;
}
/**
* Reshapes the raw [0,1] equip progress with the ease-out-back curve, then blends by intensity
* so a intensity of 0 falls back to vanilla's linear motion.
*/
public float shapeEquipProgress(float linear) {
float eased = easeOutBack(linear);
float amount = swayIntensity.get();
return linear + (eased - linear) * amount;
}
}

View file

@ -7,12 +7,14 @@
package dev.loki.lovisual.features.module.modules.visuals;
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.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.features.relations.PlayerRelations;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -35,6 +37,10 @@ public final class InvisEsp extends Module {
private final RGBAColorValue outlineColor =
color("invisesp_color", "color", "#FF00E5FF");
private final BooleanValue highlightFriends =
bool("invisesp_highlight_friends", "highlight_friends", true);
private final RGBAColorValue friendColor =
visibleWhen(color("invisesp_friend_color", "friend_color", "#FF00FF00"), highlightFriends::get);
private final NumberValue<Float> lineWidth =
num("invisesp_line_width", "line_width", 1.5f, 0.5f, 6.0f);
@ -60,11 +66,16 @@ public final class InvisEsp extends Module {
RenderState.lineWidth = previousLineWidth;
}
int color = outlineColor.getArgb();
int defaultColor = outlineColor.getArgb();
boolean highlightFriendsEnabled = highlightFriends.get();
for (Player player : mc.level.players()) {
if (player == mc.player) continue;
if (!isInvisible(player)) continue;
int color = highlightFriendsEnabled && PlayerRelations.get().isFriendOrStaff(player.getGameProfile().name())
? friendColor.getArgb()
: defaultColor;
AABB box = Esp2DRenderer.resolveWorldBox(player, tickDelta);
EspMeshBuilder.addOutlineBox(lines, box, color, color, color, color, color, color, color, color);
}

View file

@ -0,0 +1,315 @@
/*
* 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.module.modules.visuals;
import dev.loki.lovisual.config.values.BooleanValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.events.EventHandler;
import dev.loki.lovisual.events.impl.AttackEntityEvent;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.target.TargetManager;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
@ModuleInfo(
id = "lightning_target_esp",
displayName = "Lightning Target ESP",
category = ModuleCategory.VISUALS
)
public class LightningTargetESP extends Module {
private final Minecraft mc = Minecraft.getInstance();
private final NumberValue<Float> lightningCount = num("lightningCount", "count", 4.0f, 1.0f, 10.0f);
private final NumberValue<Float> lightningWidth = num("lightningWidth", "width", 1.0f, 0.1f, 3.0f);
private final BooleanValue hurtColor = bool("hurtColor", "hurt_color", true);
private float appearValue = 0f;
private Vec3 lastTargetPos = null;
private long lastAttackTime = 0L;
private LivingEntity lastAttackedEntity = null;
private final ArrayList<Bolt> bolts = new ArrayList<>();
private long lastBoltSpawn = 0L;
@EventHandler
private void onAttack(AttackEntityEvent event) {
if (event.getTarget() instanceof LivingEntity entity) {
this.lastAttackTime = System.currentTimeMillis();
this.lastAttackedEntity = entity;
}
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (mc.player == null || mc.level == null) return;
LivingEntity currentTarget = TargetManager.getTarget();
boolean hasTarget = currentTarget != null && currentTarget.isAlive();
LivingEntity activeTarget = hasTarget ? currentTarget : lastAttackedEntity;
boolean hasActiveTarget = activeTarget != null && activeTarget.isAlive();
appearValue = animateTo(appearValue, hasActiveTarget ? 1f : 0f, 0.05f);
if (hasActiveTarget) {
this.lastTargetPos = new Vec3(
activeTarget.getX(),
activeTarget.getY(),
activeTarget.getZ()
);
}
if (appearValue <= 0.001f) {
if (!hasActiveTarget) {
lastTargetPos = null;
}
return;
}
renderLightning(renderer, tickDelta, hasActiveTarget, activeTarget);
}
private void renderLightning(Renderer3D renderer, float partialTicks, boolean hasActiveTarget, LivingEntity activeTarget) {
if (lastTargetPos == null) return;
long now = System.currentTimeMillis();
int maxBolts = Math.max(4, Math.round(lightningCount.get()) * 2);
long spawnInterval = (long) Math.max(15, 65.0f / (lightningCount.get() / 3.0f));
if (hasActiveTarget && activeTarget != null && activeTarget.isAlive()) {
if (bolts.size() < maxBolts) {
if (now - lastBoltSpawn > spawnInterval) {
bolts.add(spawnBolt(activeTarget));
lastBoltSpawn = now;
}
}
}
bolts.removeIf(b -> now - b.spawn > b.life);
if (bolts.isEmpty()) return;
Vec3 basePos = lastTargetPos;
Vec3 cam = mc.gameRenderer.mainCamera().position();
float hurtPC = getHurtPC();
int baseColor = 0xFF55FFFF;
int redColor = 0xFFFF3C3C;
int glowColor = blendColor(baseColor, redColor, hurtPC);
int coreColor = blendColor(0xFFFFFFFF, redColor, hurtPC);
float widthScale = lightningWidth.get();
renderRibbonLayer(renderer, basePos, cam, bolts,
0.045f * widthScale, setAlpha(glowColor, (int) (appearValue * 45)), now, appearValue);
renderRibbonLayer(renderer, basePos, cam, bolts,
0.018f * widthScale, setAlpha(glowColor, (int) (appearValue * 95)), now, appearValue);
renderRibbonLayer(renderer, basePos, cam, bolts,
0.006f * widthScale, setAlpha(coreColor, (int) (appearValue * 235)), now, appearValue);
}
private void renderRibbonLayer(Renderer3D renderer, Vec3 base, Vec3 cam,
ArrayList<Bolt> bolts, float width, int color, long now, float appearValue) {
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED, Renderer3D.DepthMode.NONE);
if (mesh == null) return;
for (Bolt bolt : bolts) {
float alpha = boltAlpha(bolt, now) * appearValue;
if (alpha <= 0.02f) continue;
int a = (color >> 24) & 0xFF;
int layerA = (int) (a * alpha);
if (layerA <= 0 || bolt.points.size() < 2) continue;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
appendRibbonPoints(mesh, base, cam, bolt.points, width, r, g, b, layerA);
for (List<Vec3> branch : bolt.branches) {
appendRibbonPoints(mesh, base, cam, branch, width * 0.6f, r, g, b, (int) (layerA * 0.85f));
}
}
}
private Bolt spawnBolt(LivingEntity target) {
ThreadLocalRandom random = ThreadLocalRandom.current();
float radius = target.getBbWidth() * 0.5f;
float height = target.getBbHeight();
double startAngle = random.nextDouble() * Math.PI * 2.0;
double startY = 0.15 + random.nextDouble() * Math.max(0.2, height - 0.4);
Vec3 start = new Vec3(
Math.cos(startAngle) * radius * 0.7,
startY,
Math.sin(startAngle) * radius * 0.7
);
double theta = random.nextDouble() * Math.PI * 2.0;
double phi = Math.toRadians((random.nextDouble() - 0.5) * 150.0);
Vec3 dir = new Vec3(
Math.cos(phi) * Math.cos(theta),
Math.sin(phi) * 0.8 + 0.25,
Math.cos(phi) * Math.sin(theta)
).normalize();
double length = 0.35 + random.nextDouble() * 0.45;
Vec3 end = start.add(dir.scale(length));
ArrayList<Vec3> points = new ArrayList<>(List.of(start, end));
points = displacePoints(points, 0.09, 3);
ArrayList<List<Vec3>> branches = new ArrayList<>();
int branchCount = 1 + random.nextInt(3);
for (int i = 0; i < branchCount; i++) {
int anchorIndex = 1 + random.nextInt(Math.max(1, points.size() - 2));
Vec3 anchor = points.get(anchorIndex);
Vec3 branchDir = randomPerpendicular(dir);
if (random.nextBoolean()) {
branchDir = branchDir.scale(-1.0);
}
double branchLength = 0.12 + random.nextDouble() * 0.18;
Vec3 branchEnd = anchor.add(branchDir.scale(branchLength));
ArrayList<Vec3> branchPoints = new ArrayList<>(List.of(anchor, branchEnd));
branches.add(displacePoints(branchPoints, 0.05, 2));
}
return new Bolt(points, branches, 140L + random.nextLong(160L), random.nextFloat() * 6.28f);
}
private ArrayList<Vec3> displacePoints(ArrayList<Vec3> input, double amount, int passes) {
ArrayList<Vec3> points = new ArrayList<>(input);
double amt = amount;
for (int pass = 0; pass < passes; pass++) {
ArrayList<Vec3> next = new ArrayList<>(points.size() * 2);
for (int i = 0; i < points.size() - 1; i++) {
Vec3 a = points.get(i);
Vec3 b = points.get(i + 1);
next.add(a);
Vec3 dir = b.subtract(a);
if (dir.lengthSqr() < 1.0E-7) {
next.add(a.add(b.scale(0.5)));
continue;
}
Vec3 perp = randomPerpendicular(dir.normalize());
double offset = (ThreadLocalRandom.current().nextDouble() - 0.5) * 2.0 * amt;
next.add(a.add(b.scale(0.5)).add(perp.scale(offset)));
}
next.add(points.get(points.size() - 1));
points = next;
amt *= 0.5;
}
return points;
}
private Vec3 randomPerpendicular(Vec3 dir) {
Vec3 arbitrary = Math.abs(dir.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0);
Vec3 perp1 = dir.cross(arbitrary).normalize();
Vec3 perp2 = dir.cross(perp1).normalize();
double angle = ThreadLocalRandom.current().nextDouble() * Math.PI * 2.0;
return perp1.scale(Math.cos(angle)).add(perp2.scale(Math.sin(angle)));
}
private void appendRibbonPoints(MeshBuilder mesh, Vec3 base, Vec3 cam,
List<Vec3> points, float width, int r, int g, int b, int a) {
if (a <= 0 || points.size() < 2) return;
float half = width * 0.5f;
for (int i = 0; i < points.size() - 1; i++) {
Vec3 p1 = base.add(points.get(i));
Vec3 p2 = base.add(points.get(i + 1));
Vec3 segDir = p2.subtract(p1);
if (segDir.lengthSqr() < 1.0E-8) continue;
Vec3 viewDir = p1.add(p2).scale(0.5).subtract(cam);
if (viewDir.lengthSqr() < 1.0E-8) continue;
Vec3 side = segDir.cross(viewDir);
double sideLen = side.length();
if (sideLen < 1.0E-8 || !Double.isFinite(sideLen)) continue;
side = side.scale(1.0 / sideLen);
float x1 = (float) (p1.x - cam.x);
float y1 = (float) (p1.y - cam.y);
float z1 = (float) (p1.z - cam.z);
float x2 = (float) (p2.x - cam.x);
float y2 = (float) (p2.y - cam.y);
float z2 = (float) (p2.z - cam.z);
float sx = (float) side.x * half;
float sy = (float) side.y * half;
float sz = (float) side.z * half;
int i1 = mesh.vec3(x1 - sx, y1 - sy, z1 - sz).color(r, g, b, a).next();
int i2 = mesh.vec3(x1 + sx, y1 + sy, z1 + sz).color(r, g, b, a).next();
int i3 = mesh.vec3(x2 + sx, y2 + sy, z2 + sz).color(r, g, b, a).next();
int i4 = mesh.vec3(x2 - sx, y2 - sy, z2 - sz).color(r, g, b, a).next();
mesh.quad(i1, i2, i3, i4);
}
}
private float boltAlpha(Bolt bolt, long now) {
float life = Math.max(0.0f, Math.min(1.0f, (now - bolt.spawn) / (float) bolt.life));
float fade = life < 0.18f ? life / 0.18f : 1.0f - (life - 0.18f) / 0.82f;
float flicker = 0.78f + 0.22f * (float) Math.sin(now * 0.045f + bolt.phase);
return Math.max(0.0f, Math.min(1.0f, fade)) * flicker;
}
private float getHurtPC() {
if (!hurtColor.get()) return 0f;
float fromAttack = 0f;
long elapsed = System.currentTimeMillis() - lastAttackTime;
if (elapsed < 420L && lastAttackedEntity != null && lastAttackedEntity.isAlive()) {
fromAttack = 1.0f - (float) elapsed / 420.0f;
}
float progress = Math.max(0f, fromAttack);
return progress * progress * (3.0f - 2.0f * progress);
}
private int blendColor(int c1, int c2, float factor) {
factor = Math.max(0f, Math.min(1f, factor));
int r1 = (c1 >> 16) & 0xFF, g1 = (c1 >> 8) & 0xFF, b1 = c1 & 0xFF, a1 = (c1 >> 24) & 0xFF;
int r2 = (c2 >> 16) & 0xFF, g2 = (c2 >> 8) & 0xFF, b2 = c2 & 0xFF, a2 = (c2 >> 24) & 0xFF;
int r = (int) (r1 + (r2 - r1) * factor);
int g = (int) (g1 + (g2 - g1) * factor);
int b = (int) (b1 + (b2 - b1) * factor);
int a = (int) (a1 + (a2 - a1) * factor);
return (a << 24) | (r << 16) | (g << 8) | b;
}
private int setAlpha(int color, int alpha) {
alpha = Math.max(0, Math.min(255, alpha));
return (alpha << 24) | (color & 0x00FFFFFF);
}
private float animateTo(float current, float target, float delta) {
if (current < target) return Math.min(current + delta, target);
if (current > target) return Math.max(current - delta, target);
return current;
}
private static class Bolt {
final List<Vec3> points;
final List<List<Vec3>> branches;
final long spawn;
final long life;
final float phase;
Bolt(List<Vec3> points, List<List<Vec3>> branches, long life, float phase) {
this.points = points;
this.branches = branches;
this.spawn = System.currentTimeMillis();
this.life = life;
this.phase = phase;
}
}
}

View file

@ -0,0 +1,869 @@
package dev.loki.lovisual.features.module.modules.visuals;
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.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.core.LoVisualWorldMatrices;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.postprocess.PostProcessContext;
import dev.loki.lovisual.render.engine.postprocess.PostProcessManager;
import dev.loki.lovisual.render.engine.postprocess.PostProcessPass;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.render.engine.uniform.impl.WetSurfaceUniforms;
import com.mojang.blaze3d.textures.GpuTextureView;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* Volumetric rain: falling drop streaks, ground splashes with debris droplets, ground mist and lightning flashes.
* Wet-ground screen-space reflections are a separate follow-up (see {@link #renderWetSurfaceFrame}).
*/
@ModuleInfo(
id = "rain",
displayName = "Rain",
category = ModuleCategory.VISUALS,
description = "Реалистичный объёмный дождь с брызгами, туманом и молниями"
)
public final class Rain extends Module implements PostProcessPass {
private final Matrix4f wetSurfaceProjection = new Matrix4f();
private final Matrix4f wetSurfaceInvView = new Matrix4f();
private boolean wetSurfaceDepthSupported = true;
{
PostProcessManager.register(this);
}
private static final double GRAVITY = 22.0;
private static final double TERMINAL_VELOCITY = 34.0;
private static final int RIPPLE_SEGMENTS = 14;
private static final int MAX_DROPS = 4200;
private static final int MAX_MIST = 90;
private final Minecraft mc = Minecraft.getInstance();
private final EnumValue<Preset> preset = enumMode("preset", Preset.RAIN, Preset.values());
private final NumberValue<Float> density = num("density", 1.0f, 0.1f, 3.0f);
private final NumberValue<Float> radius = num("radius", 26.0f, 8.0f, 64.0f);
private final NumberValue<Float> altitude = num("altitude", 16.0f, 6.0f, 40.0f);
private final NumberValue<Float> dropSize = num("drop_size", 1.0f, 0.3f, 2.5f);
private final NumberValue<Float> fallSpeed = num("fall_speed", 1.0f, 0.3f, 2.5f);
private final NumberValue<Float> wind = num("wind", 1.0f, 0.0f, 3.0f);
private final NumberValue<Float> opacity = num("opacity", 1.0f, 0.1f, 2.0f);
private final BooleanValue splashes = bool("splashes", true);
private final BooleanValue droplets = visibleWhen(bool("splash_droplets", true), splashes::get);
private final BooleanValue groundMist = bool("ground_mist", true);
private final BooleanValue lightning = bool("lightning", false);
private final BooleanValue skyCheck = bool("sky_check", true);
private final BooleanValue onlyWhenRaining = bool("only_when_raining", false);
private final EnumValue<ColorMode> colorMode = enumMode("color_mode", ColorMode.REALISTIC, ColorMode.values());
private final RGBAColorValue customColor = visibleWhen(color("color", "#FFBFD8E6"), () -> colorMode.get() == ColorMode.CUSTOM);
private final BooleanValue wetGround = bool("wet_ground", true);
private final NumberValue<Float> wetness = visibleWhen(num("wetness", 1.25f, 0.1f, 2.0f), wetGround::get);
private final NumberValue<Float> puddleCoverage = visibleWhen(num("puddle_coverage", 0.90f, 0.05f, 1.0f), wetGround::get);
private final NumberValue<Float> reflectionStrength = visibleWhen(num("reflection_strength", 1.55f, 0.1f, 2.0f), wetGround::get);
private final NumberValue<Float> reflectionDistance = visibleWhen(num("reflection_distance", 56.0f, 12.0f, 96.0f), wetGround::get);
private final EnumValue<ReflectionQuality> reflectionQuality = visibleWhen(enumMode("reflection_quality", ReflectionQuality.BALANCED, ReflectionQuality.values()), wetGround::get);
private final BooleanValue puddleRipples = visibleWhen(bool("puddle_ripples", true), wetGround::get);
private final NumberValue<Float> rippleStrength = visibleWhen(num("ripple_strength", 0.8f, 0.0f, 1.6f), () -> wetGround.get() && puddleRipples.get());
private final List<Drop> drops = new ArrayList<>();
private final List<Splash> splashList = new ArrayList<>();
private final List<Droplet> dropletList = new ArrayList<>();
private final List<Mist> mistList = new ArrayList<>();
private final Map<Long, Integer> groundCache = new HashMap<>();
private final BlockPos.MutableBlockPos scratchPos = new BlockPos.MutableBlockPos();
private final Random random = new Random();
private long lastFrameNanos;
private float clock;
private float flash;
private float nextFlashIn = 9.0f;
private int cacheTicks;
@Override
public void onEnable() {
lastFrameNanos = 0L;
wetSurfaceDepthSupported = true;
clearAll();
}
@Override
public void onDisable() {
clearAll();
}
private void clearAll() {
drops.clear();
splashList.clear();
dropletList.clear();
mistList.clear();
groundCache.clear();
flash = 0.0f;
}
@Override
public void onTick() {
if (mc.player == null || mc.level == null) {
clearAll();
return;
}
if (++cacheTicks > 30) {
cacheTicks = 0;
groundCache.clear();
}
}
private float presetDensity() {
return switch (preset.get()) {
case DRIZZLE -> 0.45f;
case DOWNPOUR -> 1.9f;
case STORM -> 2.8f;
case RAIN -> 1.0f;
};
}
private float presetWind() {
return switch (preset.get()) {
case DRIZZLE -> 0.5f;
case DOWNPOUR -> 1.35f;
case STORM -> 2.4f;
case RAIN -> 1.0f;
};
}
private float presetMass() {
return switch (preset.get()) {
case DRIZZLE -> 0.55f;
case DOWNPOUR -> 1.25f;
case STORM -> 1.5f;
case RAIN -> 1.0f;
};
}
private int targetDropCount() {
float r = radius.get();
float area = r * r * 0.0155f;
int count = (int) (area * density.get() * presetDensity() * 26.0f);
return Mth.clamp(count, 40, MAX_DROPS);
}
private int resolveColorArgb() {
return switch (colorMode.get()) {
case CUSTOM -> customColor.getArgb();
case THEME -> Theme.theme().accent();
case REALISTIC -> 0xFFC8DCE8;
};
}
private float random(float min, float max) {
return min + random.nextFloat() * (max - min);
}
private int surfaceY(int x, int z) {
long key = (((long) x) << 32) ^ (z & 0xFFFFFFFFL);
Integer cached = groundCache.get(key);
if (cached != null) return cached;
int top;
try {
top = mc.level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, new BlockPos(x, 0, z)).getY();
} catch (Throwable ignored) {
top = mc.level.getMinY();
}
if (groundCache.size() < 20000) groundCache.put(key, top);
return top;
}
private boolean isBlocking(double x, double y, double z) {
if (mc.level == null) return false;
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
BlockState state = mc.level.getBlockState(scratchPos);
if (!state.getFluidState().isEmpty()) return true;
if (state.isAir()) return false;
try {
return !state.getCollisionShape(mc.level, scratchPos).isEmpty();
} catch (Throwable ignored) {
return true;
}
}
private boolean isWater(double x, double y, double z) {
if (mc.level == null) return false;
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
try {
return !mc.level.getBlockState(scratchPos).getFluidState().isEmpty();
} catch (Throwable ignored) {
return false;
}
}
private boolean skyVisible(double x, double y, double z) {
if (!skyCheck.get() || mc.level == null) return true;
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
try {
return mc.level.canSeeSky(scratchPos);
} catch (Throwable ignored) {
return true;
}
}
private void spawnDrop(Vec3 center, boolean fromTop) {
float r = radius.get();
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = Math.sqrt(random.nextDouble()) * r;
double x = center.x + Math.cos(angle) * dist;
double z = center.z + Math.sin(angle) * dist;
double y = fromTop
? center.y + altitude.get() * random(0.75f, 1.0f)
: center.y + random(-4.0f, altitude.get());
if (!skyVisible(x, y, z)) return;
Drop drop = new Drop();
drop.x = x;
drop.y = y;
drop.z = z;
drop.mass = random(0.6f, 1.4f) * presetMass();
drop.vy = -random(6.0f, 11.0f) * drop.mass;
drop.phase = random(0.0f, 62.8f);
drop.brightness = random(0.55f, 1.0f);
drop.ground = surfaceY(Mth.floor(x), Mth.floor(z));
drops.add(drop);
}
private void updateDrops(Vec3 center, float dt) {
int target = targetDropCount();
drops.removeIf(d -> {
double dx = d.x - center.x;
double dz = d.z - center.z;
float r = radius.get() + 6.0f;
return d.dead || dx * dx + dz * dz > r * r || d.y < center.y - altitude.get() - 12.0;
});
boolean fill = drops.size() < target / 2;
int spawnBudget = fill ? Math.min(target - drops.size(), 900) : Math.min(target - drops.size(), 260);
for (int i = 0; i < spawnBudget; i++) {
spawnDrop(center, !fill);
}
float speedMul = fallSpeed.get();
float windMul = wind.get() * presetWind();
for (int i = 0, size = drops.size(); i < size; i++) {
Drop d = drops.get(i);
double gust = 0.65 + 0.35 * Math.sin(clock * 0.55 + d.phase * 0.15)
+ 0.18 * Math.sin(clock * 1.7 + d.phase);
double windX = windMul * 3.1 * gust;
double windZ = windMul * 1.7 * Math.sin(clock * 0.31 + d.phase * 0.05) * gust;
double drag = 1.0 - Math.min(0.92, Math.abs(d.vy) / (TERMINAL_VELOCITY * d.mass));
d.vy -= GRAVITY * d.mass * drag * dt;
if (d.vy < -TERMINAL_VELOCITY * d.mass) d.vy = -TERMINAL_VELOCITY * d.mass;
d.vx += (windX - d.vx) * Math.min(1.0, dt * 3.2);
d.vz += (windZ - d.vz) * Math.min(1.0, dt * 3.2);
double nx = d.x + d.vx * dt * speedMul;
double ny = d.y + d.vy * dt * speedMul;
double nz = d.z + d.vz * dt * speedMul;
boolean hit = false;
if (ny <= d.ground + 2.5) {
if (isBlocking(nx, ny, nz)) {
hit = true;
} else if (ny <= d.ground - 3.0) {
d.dead = true;
}
}
if (hit) {
if (splashes.get()) addSplash(nx, ny, nz, d);
d.dead = true;
continue;
}
d.x = nx;
d.y = ny;
d.z = nz;
}
}
private void addSplash(double x, double y, double z, Drop d) {
if (splashList.size() > 700) return;
double impactY = Math.floor(y) + 1.0005;
Splash splash = new Splash();
splash.x = x;
splash.y = impactY;
splash.z = z;
splash.life = 0.0f;
splash.maxLife = random(0.30f, 0.52f);
splash.scale = (float) (0.32 + 0.22 * d.mass);
splash.water = isWater(x, y - 0.15, z);
splashList.add(splash);
if (!droplets.get() || dropletList.size() > 1400) return;
int shards = splash.water ? 4 : 3;
for (int i = 0; i < shards; i++) {
double a = random.nextDouble() * Math.PI * 2.0;
double sp = random(1.1f, 2.6f) * d.mass;
Droplet drop = new Droplet();
drop.x = x;
drop.y = impactY + 0.02;
drop.z = z;
drop.vx = Math.cos(a) * sp * 0.45 + d.vx * 0.08;
drop.vz = Math.sin(a) * sp * 0.45 + d.vz * 0.08;
drop.vy = random(1.9f, 4.1f);
drop.maxLife = random(0.28f, 0.5f);
dropletList.add(drop);
}
}
private void updateDroplets(float dt) {
for (int i = dropletList.size() - 1; i >= 0; i--) {
Droplet dr = dropletList.get(i);
dr.life += dt;
if (dr.life >= dr.maxLife) {
dropletList.remove(i);
continue;
}
dr.vy -= GRAVITY * 0.55 * dt;
dr.x += dr.vx * dt;
dr.y += dr.vy * dt;
dr.z += dr.vz * dt;
}
}
private void updateSplashes(float dt) {
for (int i = splashList.size() - 1; i >= 0; i--) {
Splash s = splashList.get(i);
s.life += dt;
if (s.life >= s.maxLife) splashList.remove(i);
}
}
private void updateMist(Vec3 center, float dt) {
if (!groundMist.get()) {
mistList.clear();
return;
}
int target = (int) Mth.clamp(22 * density.get() * presetDensity(), 6, MAX_MIST);
mistList.removeIf(m -> {
m.life += dt;
double dx = m.x - center.x;
double dz = m.z - center.z;
float r = radius.get();
m.x += m.vx * dt;
m.z += m.vz * dt;
return m.life > m.maxLife || dx * dx + dz * dz > r * r;
});
while (mistList.size() < target) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = Math.sqrt(random.nextDouble()) * radius.get() * 0.85;
double x = center.x + Math.cos(angle) * dist;
double z = center.z + Math.sin(angle) * dist;
int ground = surfaceY(Mth.floor(x), Mth.floor(z));
Mist m = new Mist();
m.x = x;
m.z = z;
m.y = ground + random(0.05f, 0.9f);
m.size = random(2.2f, 5.6f);
m.maxLife = random(2.4f, 5.5f);
m.life = random(0.0f, 1.0f);
m.vx = random(-0.35f, 0.35f) * wind.get();
m.vz = random(-0.35f, 0.35f) * wind.get();
mistList.add(m);
}
}
private void updateLightning(float dt) {
if (!lightning.get()) {
flash = 0.0f;
return;
}
flash = Math.max(0.0f, flash - dt * 3.4f);
nextFlashIn -= dt;
if (nextFlashIn <= 0.0f) {
flash = random(0.55f, 1.0f);
nextFlashIn = random(6.0f, 16.0f);
}
}
private float ambientLight(Camera camera) {
try {
int light = mc.level.getBrightness(net.minecraft.world.level.LightLayer.BLOCK, camera.blockPosition());
return Mth.clamp(light / 15.0f, 0.3f, 1.0f);
} catch (Throwable ignored) {
return 1.0f;
}
}
// --- Wet-ground screen-space reflection pass (PostProcessPass) ---
private int reflectionSteps() {
return switch (reflectionQuality.get()) {
case HIGH -> 16;
case BALANCED -> 10;
case PERFORMANCE -> 6;
};
}
private float rainAmount() {
return Mth.clamp(0.55f + density.get() * presetDensity() * 0.28f, 0.55f, 1.45f);
}
@Override
public boolean isActive() {
return isEnabled()
&& wetGround.get()
&& wetSurfaceDepthSupported
&& mc.player != null
&& mc.level != null
&& (!onlyWhenRaining.get() || mc.level.isRaining());
}
@Override
public int getPriority() {
return 2;
}
@Override
public Phase getPhase() {
return Phase.PRE_HAND;
}
@Override
public boolean render(GpuTextureView src, GpuTextureView dst, float tickDelta) {
return false;
}
@Override
public boolean render(PostProcessContext context, GpuTextureView src, GpuTextureView dst) {
if (!isActive() || context == null || src == null || dst == null) return false;
GpuTextureView mainDepth = context.mainDepth();
boolean hasDepth = mainDepth != null;
if (!hasDepth) mainDepth = src;
Matrix4f projection = LoVisualWorldMatrices.renderProjectionMatrix();
if (projection == null) projection = RenderState.worldProjection;
wetSurfaceProjection.set(projection);
wetSurfaceInvView.set(new Matrix4f().rotation(RenderState.cameraRotation));
WetSurfaceUniforms.update(
wetSurfaceProjection,
wetSurfaceInvView,
(float) RenderState.cameraPos.x, (float) RenderState.cameraPos.y, (float) RenderState.cameraPos.z,
context.width() > 0 ? 1.0f / context.width() : 0.0f,
context.height() > 0 ? 1.0f / context.height() : 0.0f,
clock,
hasDepth,
wetness.get(),
puddleCoverage.get(),
reflectionStrength.get(),
puddleRipples.get() ? rippleStrength.get() : 0.0f,
reflectionDistance.get(),
reflectionSteps(),
rainAmount(),
puddleRipples.get()
);
try {
FullScreenRenderer.ensureInit();
FullScreenRenderer.begin("LoVisual WetSurface Pass")
.attachment(dst)
.pipeline(LoVisualRenderPipelines.WET_SURFACE)
.uniform("WetSurface", WetSurfaceUniforms.get())
.sampler("u_Texture", src, PostProcessManager.getSampler())
.sampler("u_MainDepth", mainDepth, PostProcessManager.getSampler())
.end();
} catch (Throwable t) {
wetSurfaceDepthSupported = false;
return false;
}
return true;
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return;
if (mc.gameRenderer.mainCamera() == null) return;
if (onlyWhenRaining.get() && !mc.level.isRaining()) {
clearAll();
return;
}
Camera camera = mc.gameRenderer.mainCamera();
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
lastFrameNanos = now;
dt = Mth.clamp(dt, 0.0f, 0.05f);
clock += dt;
Vec3 cam = camera.position();
updateDrops(cam, dt);
updateSplashes(dt);
updateDroplets(dt);
updateMist(cam, dt);
updateLightning(dt);
if (drops.isEmpty() && splashList.isEmpty() && dropletList.isEmpty() && mistList.isEmpty()) return;
int colorArgb = resolveColorArgb();
float r = ((colorArgb >>> 16) & 0xFF) / 255f;
float g = ((colorArgb >>> 8) & 0xFF) / 255f;
float b = (colorArgb & 0xFF) / 255f;
float[] rgb = {r, g, b};
float ambient = ambientLight(camera);
float brightnessMul = Mth.clamp(ambient + flash * 0.9f, 0.0f, 1.65f);
float alphaMul = opacity.get() * brightnessMul;
Quaternionf camRot = RenderState.cameraRotation;
float camYaw = camera.yRot();
MeshBuilder blendMesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED, Renderer3D.DepthMode.MAIN);
if (blendMesh != null) {
if (groundMist.get()) drawMist(blendMesh, camRot, rgb, alphaMul);
drawStreaks(blendMesh, camYaw, rgb, alphaMul, false);
if (splashes.get()) drawRipples(blendMesh, rgb, alphaMul);
}
MeshBuilder additiveMesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH, Renderer3D.DepthMode.PRE_DEPTH);
if (additiveMesh != null) {
drawStreaks(additiveMesh, camYaw, rgb, alphaMul, true);
if (splashes.get()) {
drawCrowns(additiveMesh, camYaw, rgb, alphaMul);
if (droplets.get()) drawDroplets(additiveMesh, camYaw, rgb, alphaMul);
}
}
}
private static int packColor(float r, float g, float b, float a) {
int ai = Mth.clamp(Math.round(a * 255.0f), 0, 255);
int ri = Mth.clamp(Math.round(r * 255.0f), 0, 255);
int gi = Mth.clamp(Math.round(g * 255.0f), 0, 255);
int bi = Mth.clamp(Math.round(b * 255.0f), 0, 255);
return (ai << 24) | (ri << 16) | (gi << 8) | bi;
}
private static Vector3f rotated(Quaternionf rot, float x, float y, float z) {
return new Vector3f(x, y, z).rotate(rot);
}
private void drawStreaks(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul, boolean highlight) {
if (drops.isEmpty()) return;
float sizeMul = dropSize.get();
float r = radius.get();
float fadeStart = r * 0.62f;
for (int i = 0, size = drops.size(); i < size; i++) {
Drop d = drops.get(i);
double speed = Math.sqrt(d.vx * d.vx + d.vy * d.vy + d.vz * d.vz);
if (speed < 0.0001) continue;
float length = (float) Mth.clamp((float) (speed * 0.055 * sizeMul), 0.16f, 2.6f);
float width = (float) (0.014 + 0.019 * d.mass) * sizeMul * (highlight ? 0.45f : 1.0f);
float yawRad = (float) Math.toRadians(camYaw);
double rightX = Math.cos(yawRad);
double rightZ = Math.sin(yawRad);
double horizontal = d.vx * rightX + d.vz * rightZ;
float tilt = (float) Math.toDegrees(Math.atan2(horizontal, Math.abs(d.vy) + 0.0001));
tilt = Mth.clamp(tilt, -55.0f, 55.0f);
Quaternionf orient = new Quaternionf()
.rotateY((float) Math.toRadians(-camYaw))
.rotateZ((float) Math.toRadians(-tilt));
float half = width * 0.5f;
float top = length * 0.5f;
float bottom = -length * 0.5f;
float headAlpha = alphaMul * d.brightness * (highlight ? 0.30f : 0.42f);
if (headAlpha <= 0.006f) continue;
headAlpha = Math.min(headAlpha, 0.95f);
float tailAlpha = 0.0f;
float headWidth = half;
float tailWidth = half * 0.35f;
Vector3f p1 = rotated(orient, -tailWidth, top, 0.0f);
Vector3f p2 = rotated(orient, tailWidth, top, 0.0f);
Vector3f p3 = rotated(orient, headWidth, bottom, 0.0f);
Vector3f p4 = rotated(orient, -headWidth, bottom, 0.0f);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(d.x + p1.x, d.y + p1.y, d.z + p1.z).color(packColorObj(rgb, tailAlpha)).next();
int i2 = mesh.vec3(d.x + p2.x, d.y + p2.y, d.z + p2.z).color(packColorObj(rgb, tailAlpha)).next();
int i3 = mesh.vec3(d.x + p3.x, d.y + p3.y, d.z + p3.z).color(packColorObj(rgb, headAlpha)).next();
int i4 = mesh.vec3(d.x + p4.x, d.y + p4.y, d.z + p4.z).color(packColorObj(rgb, headAlpha)).next();
mesh.quad(i1, i2, i3, i4);
}
}
private void drawRipples(MeshBuilder mesh, float[] rgb, float alphaMul) {
if (splashList.isEmpty()) return;
for (int i = 0, size = splashList.size(); i < size; i++) {
Splash s = splashList.get(i);
float progress = Mth.clamp(s.life / s.maxLife, 0.0f, 1.0f);
float eased = 1.0f - (1.0f - progress) * (1.0f - progress);
float outer = s.scale * (0.18f + eased * (s.water ? 1.35f : 0.85f));
float inner = outer * (0.55f + 0.35f * eased);
float alpha = alphaMul * (1.0f - progress) * (s.water ? 0.5f : 0.38f);
if (alpha <= 0.01f) continue;
for (int seg = 0; seg < RIPPLE_SEGMENTS; seg++) {
double a0 = (Math.PI * 2.0 / RIPPLE_SEGMENTS) * seg;
double a1 = (Math.PI * 2.0 / RIPPLE_SEGMENTS) * (seg + 1);
float ix0 = (float) (Math.cos(a0) * inner);
float iz0 = (float) (Math.sin(a0) * inner);
float ix1 = (float) (Math.cos(a1) * inner);
float iz1 = (float) (Math.sin(a1) * inner);
float ox0 = (float) (Math.cos(a0) * outer);
float oz0 = (float) (Math.sin(a0) * outer);
float ox1 = (float) (Math.cos(a1) * outer);
float oz1 = (float) (Math.sin(a1) * outer);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(s.x + ix0, s.y, s.z + iz0).color(packColorObj(rgb, alpha)).next();
int i2 = mesh.vec3(s.x + ix1, s.y, s.z + iz1).color(packColorObj(rgb, alpha)).next();
int i3 = mesh.vec3(s.x + ox1, s.y, s.z + oz1).color(packColorObj(rgb, 0.0f)).next();
int i4 = mesh.vec3(s.x + ox0, s.y, s.z + oz0).color(packColorObj(rgb, 0.0f)).next();
mesh.quad(i1, i2, i3, i4);
}
}
}
private void drawCrowns(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul) {
if (splashList.isEmpty()) return;
Quaternionf orient = new Quaternionf().rotateY((float) Math.toRadians(-camYaw));
for (int i = 0, size = splashList.size(); i < size; i++) {
Splash s = splashList.get(i);
float progress = Mth.clamp(s.life / s.maxLife, 0.0f, 1.0f);
if (progress > 0.55f) continue;
float local = progress / 0.55f;
float height = s.scale * (0.55f * (float) Math.sin(local * Math.PI));
float alpha = alphaMul * (1.0f - local) * 0.32f;
if (alpha <= 0.01f || height <= 0.005f) continue;
float base = s.scale * 0.16f;
Vector3f p1 = rotated(orient, -base * 0.25f, height, 0.0f);
Vector3f p2 = rotated(orient, base * 0.25f, height, 0.0f);
Vector3f p3 = rotated(orient, base, 0.0f, 0.0f);
Vector3f p4 = rotated(orient, -base, 0.0f, 0.0f);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(s.x + p1.x, s.y + p1.y, s.z + p1.z).color(packColorObj(rgb, 0.0f)).next();
int i2 = mesh.vec3(s.x + p2.x, s.y + p2.y, s.z + p2.z).color(packColorObj(rgb, 0.0f)).next();
int i3 = mesh.vec3(s.x + p3.x, s.y + p3.y, s.z + p3.z).color(packColorObj(rgb, alpha)).next();
int i4 = mesh.vec3(s.x + p4.x, s.y + p4.y, s.z + p4.z).color(packColorObj(rgb, alpha)).next();
mesh.quad(i1, i2, i3, i4);
}
}
private void drawDroplets(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul) {
if (dropletList.isEmpty()) return;
float sizeMul = dropSize.get();
float yawRad = (float) Math.toRadians(camYaw);
for (int i = 0, size = dropletList.size(); i < size; i++) {
Droplet d = dropletList.get(i);
float progress = Mth.clamp(d.life / d.maxLife, 0.0f, 1.0f);
float alpha = alphaMul * (1.0f - progress) * 0.5f;
if (alpha <= 0.01f) continue;
double speed = Math.sqrt(d.vx * d.vx + d.vy * d.vy + d.vz * d.vz);
float length = (float) Mth.clamp((float) (speed * 0.03 * sizeMul), 0.04f, 0.4f);
float width = 0.016f * sizeMul;
double horizontal = d.vx * Math.cos(yawRad) + d.vz * Math.sin(yawRad);
float tilt = (float) Math.toDegrees(Math.atan2(horizontal, -d.vy + 0.0001));
Quaternionf orient = new Quaternionf()
.rotateY((float) Math.toRadians(-camYaw))
.rotateZ((float) Math.toRadians(-tilt));
Vector3f p1 = rotated(orient, -width * 0.4f, length * 0.5f, 0.0f);
Vector3f p2 = rotated(orient, width * 0.4f, length * 0.5f, 0.0f);
Vector3f p3 = rotated(orient, width, -length * 0.5f, 0.0f);
Vector3f p4 = rotated(orient, -width, -length * 0.5f, 0.0f);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(d.x + p1.x, d.y + p1.y, d.z + p1.z).color(packColorObj(rgb, 0.0f)).next();
int i2 = mesh.vec3(d.x + p2.x, d.y + p2.y, d.z + p2.z).color(packColorObj(rgb, 0.0f)).next();
int i3 = mesh.vec3(d.x + p3.x, d.y + p3.y, d.z + p3.z).color(packColorObj(rgb, alpha)).next();
int i4 = mesh.vec3(d.x + p4.x, d.y + p4.y, d.z + p4.z).color(packColorObj(rgb, alpha)).next();
mesh.quad(i1, i2, i3, i4);
}
}
private void drawMist(MeshBuilder mesh, Quaternionf camRot, float[] rgb, float alphaMul) {
if (mistList.isEmpty()) return;
for (int i = 0, size = mistList.size(); i < size; i++) {
Mist mist = mistList.get(i);
float progress = Mth.clamp(mist.life / mist.maxLife, 0.0f, 1.0f);
float fade = (float) Math.sin(progress * Math.PI);
float alpha = alphaMul * fade * 0.05f;
if (alpha <= 0.004f) continue;
float half = mist.size * 0.5f;
float top = mist.size * 0.42f;
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot);
double bx = mist.x, by = mist.y, bz = mist.z;
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(bx - right.x * half, by - right.y * half + up.y * top, bz - right.z * half).color(packColorObj(rgb, 0.0f)).next();
int i2 = mesh.vec3(bx + right.x * half, by + right.y * half + up.y * top, bz + right.z * half).color(packColorObj(rgb, 0.0f)).next();
int i3 = mesh.vec3(bx + right.x * half, by + right.y * half, bz + right.z * half).color(packColorObj(rgb, alpha)).next();
int i4 = mesh.vec3(bx - right.x * half, by - right.y * half, bz - right.z * half).color(packColorObj(rgb, alpha)).next();
mesh.quad(i1, i2, i3, i4);
}
}
private dev.loki.lovisual.render.engine.color.RenderColor packColorObj(float[] rgb, float a) {
return new dev.loki.lovisual.render.engine.color.RenderColor(packColor(rgb[0], rgb[1], rgb[2], a));
}
public enum Preset implements EnumValue.IdProvider {
DRIZZLE("drizzle"),
RAIN("rain"),
DOWNPOUR("downpour"),
STORM("storm");
private final String id;
Preset(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
public enum ColorMode implements EnumValue.IdProvider {
REALISTIC("realistic"),
THEME("theme"),
CUSTOM("custom");
private final String id;
ColorMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
public enum ReflectionQuality implements EnumValue.IdProvider {
PERFORMANCE("performance"),
BALANCED("balanced"),
HIGH("high");
private final String id;
ReflectionQuality(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
private static final class Drop {
double x, y, z;
double vx, vy, vz;
double mass = 1.0;
double ground;
float phase;
float brightness = 1.0f;
boolean dead;
}
private static final class Droplet {
double x, y, z;
double vx, vy, vz;
float life;
float maxLife = 0.4f;
}
private static final class Splash {
double x, y, z;
float life;
float maxLife = 0.4f;
float scale = 0.4f;
boolean water;
}
private static final class Mist {
double x, y, z;
double vx, vz;
float size = 3.0f;
float life;
float maxLife = 4.0f;
}
}

View file

@ -5,11 +5,17 @@ import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.render.engine.postprocess.SkyShaderRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
//todo Description
@ModuleInfo(id = "shadersky", displayName = "ShaderSky", category = ModuleCategory.VISUALS)
public class ShaderSky extends Module {
private final Minecraft mc = Minecraft.getInstance();
private static final String SETTING_MODE = "mode";
private static final String SETTING_SPEED = "speed";
private static final String SETTING_SCALE = "scale";
@ -23,7 +29,7 @@ public class ShaderSky extends Module {
"shaderSkyMode",
SETTING_MODE,
"Thunder",
"Thunder", "Pulsar"
"Thunder", "Pulsar", "Sky", "Water", "Caustic"
);
private final NumberValue<Float> speed =
num("shaderSkySpeed", SETTING_SPEED, 1.0f, 0.1f, 5.0f);
@ -75,4 +81,41 @@ public class ShaderSky extends Module {
private boolean isThunderMode() {
return "Thunder".equals(mode.get());
}
}
private boolean isSkyShaderMode() {
String m = mode.get();
return "Sky".equals(m) || "Water".equals(m) || "Caustic".equals(m);
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onEnable() {
SkyShaderRenderer.reset();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || mc.level == null || mc.player == null) return;
if (isSkyShaderMode()) {
SkyShaderRenderer.Mode skyMode = switch (mode.get()) {
case "Water" -> SkyShaderRenderer.Mode.WATER;
case "Caustic" -> SkyShaderRenderer.Mode.CAUSTIC;
default -> SkyShaderRenderer.Mode.SKY;
};
SkyShaderRenderer.render(
mc,
skyMode,
speed.get(),
scale.get(),
intensity.get(),
alpha.get(),
0xFF6FA8DC & 0x00FFFFFF
);
}
}
}

View file

@ -0,0 +1,432 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
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.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.features.module.WorldPhase;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
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.MediaSessionService;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ThreadLocalRandom;
@ModuleInfo(
id = "kineticlyrics",
displayName = "KineticLyrics",
aliases = {"lyrics"},
category = ModuleCategory.VISUALS,
description = "Рендерит текст текущей песни в 3D-пространстве вокруг игрока"
)
public final class KineticLyrics extends Module {
private final Minecraft mc = Minecraft.getInstance();
private final EnumValue<AnimationMode> animation =
enumMode("animation", AnimationMode.LYRIC_FLOW, AnimationMode.values());
private final EnumValue<ColorMode> colorMode =
enumMode("color_mode", ColorMode.THEME, ColorMode.values());
private final RGBAColorValue staticColor =
visibleWhen(color("static_color", "#FFFFFFFF"), () -> colorMode.get() == ColorMode.STATIC);
private final EnumValue<FontMode> fontMode =
enumMode("font", FontMode.ONEST_BOLD, FontMode.values());
private final EnumValue<SplitMode> splitMode =
enumMode("split_mode", SplitMode.SMART_SPLIT, SplitMode.values());
private final NumberValue<Integer> maxLines =
num("max_lines", 3, 1, 6);
private final NumberValue<Float> minDistance =
num("min_distance", 2.0f, 1.2f, 4.0f);
private final NumberValue<Float> maxDistance =
num("max_distance", 4.5f, 2.0f, 7.0f);
private final NumberValue<Float> arcSpread =
num("arc_spread", 70.0f, 20.0f, 85.0f);
private final NumberValue<Float> floatHeight =
num("float_height", 0.5f, 0.1f, 2.0f);
private final NumberValue<Integer> timeOffsetMs =
num("time_offset_ms", 0, -5000, 5000);
private final BooleanValue debugMode =
bool("debug", false);
private final List<LyricLine> rawLyrics = new ArrayList<>();
private final List<LyricLine> lyricsQueue = new ArrayList<>();
private final List<LyricParticle3D> activeParticles = new CopyOnWriteArrayList<>();
private int currentLyricIndex = 0;
private String lastTrackKey = "";
private String currentTrackTitle = "";
private String currentTrackArtist = "";
private long lastAudioTimeMs = -1L;
private boolean isPlaying = false;
@Override
public void onEnable() {
resetPlaybackState();
}
@Override
public void onDisable() {
resetPlaybackState();
}
private void resetPlaybackState() {
activeParticles.clear();
rawLyrics.clear();
lyricsQueue.clear();
lastTrackKey = "";
currentTrackTitle = "";
currentTrackArtist = "";
currentLyricIndex = 0;
lastAudioTimeMs = -1L;
isPlaying = false;
}
@Override
public void onTick() {
if (!isEnabled() || mc.player == null) return;
syncPlaybackTime();
}
private void syncPlaybackTime() {
MediaSessionService.Snapshot snapshot = MediaSessionService.get().snapshot();
if (!snapshot.hasSession() || snapshot.title().isBlank()) {
if (isPlaying) {
isPlaying = false;
activeParticles.clear();
}
return;
}
String title = snapshot.title();
String artist = snapshot.artist();
String key = (title + " - " + artist).toLowerCase();
if (!key.equalsIgnoreCase(lastTrackKey)) {
loadTrack(title, artist, key);
return;
}
if (!isPlaying && !lyricsQueue.isEmpty()) {
isPlaying = true;
}
if (lyricsQueue.isEmpty()) return;
long audioTimeMs = Math.max(0L, snapshot.predictedPositionSeconds() * 1000L + timeOffsetMs.get());
if (lastAudioTimeMs >= 0 && Math.abs(audioTimeMs - lastAudioTimeMs) > 1500L) {
resyncQueue(audioTimeMs);
}
lastAudioTimeMs = audioTimeMs;
advanceQueue(audioTimeMs);
}
private void loadTrack(String title, String artist, String key) {
this.currentTrackTitle = title;
this.currentTrackArtist = artist;
this.lastTrackKey = key;
activeParticles.clear();
rawLyrics.clear();
lyricsQueue.clear();
currentLyricIndex = 0;
lastAudioTimeMs = -1L;
isPlaying = false;
LrcLibClient.fetchLyricsAsync(title, artist, debugMode.get()).thenAccept(lines -> {
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
activeParticles.clear();
rawLyrics.clear();
lyricsQueue.clear();
rawLyrics.addAll(lines);
rebuildLyricsQueue();
currentLyricIndex = 0;
isPlaying = !lyricsQueue.isEmpty();
});
}
private void rebuildLyricsQueue() {
lyricsQueue.clear();
if (splitMode.get() == SplitMode.SMART_SPLIT) {
lyricsQueue.addAll(LyricLineSplitter.splitLongLines(rawLyrics));
} else {
lyricsQueue.addAll(rawLyrics);
}
}
private void resyncQueue(long audioTimeMs) {
activeParticles.clear();
currentLyricIndex = 0;
long now = System.currentTimeMillis();
for (int i = 0; i < lyricsQueue.size(); i++) {
LyricLine line = lyricsQueue.get(i);
long nextStartMs = (i + 1 < lyricsQueue.size()) ? lyricsQueue.get(i + 1).timestampMs() : line.timestampMs() + 3500L;
long durationMs = Math.max(1200L, Math.min(5000L, nextStartMs - line.timestampMs()));
if (audioTimeMs >= line.timestampMs()) {
long timeSinceLine = audioTimeMs - line.timestampMs();
if (timeSinceLine < durationMs) {
spawnLyricParticle(line.text(), now - timeSinceLine, durationMs);
}
currentLyricIndex = i + 1;
} else {
break;
}
}
}
private void advanceQueue(long audioTimeMs) {
if (!isPlaying || lyricsQueue.isEmpty() || mc.player == null) return;
long now = System.currentTimeMillis();
while (currentLyricIndex < lyricsQueue.size()) {
LyricLine line = lyricsQueue.get(currentLyricIndex);
long nextStartMs = (currentLyricIndex + 1 < lyricsQueue.size()) ? lyricsQueue.get(currentLyricIndex + 1).timestampMs() : line.timestampMs() + 3500L;
long durationMs = Math.max(1400L, Math.min(5500L, nextStartMs - line.timestampMs()));
if (audioTimeMs >= line.timestampMs()) {
long timeSinceLine = audioTimeMs - line.timestampMs();
if (timeSinceLine < durationMs) {
spawnLyricParticle(line.text(), now - timeSinceLine, durationMs);
}
currentLyricIndex++;
} else {
break;
}
}
activeParticles.removeIf(particle -> particle.isDead(now));
}
private Vec3 findNonOverlappingSpawnPos(Vec3 headPos, float cameraYaw) {
double spread = arcSpread.get();
double minD = minDistance.get();
double maxD = Math.max(minD + 0.5, maxDistance.get());
Vec3 bestPos = null;
double maxMinDistance = -1.0;
for (int attempt = 0; attempt < 16; attempt++) {
double randomAngleOffset = ThreadLocalRandom.current().nextDouble(-spread, spread);
double targetYawDeg = cameraYaw + randomAngleOffset;
double distance = ThreadLocalRandom.current().nextDouble(minD, maxD);
double yOffset = ThreadLocalRandom.current().nextDouble(-0.25, 0.55);
double yawRad = Math.toRadians(targetYawDeg);
double dirX = -Math.sin(yawRad);
double dirZ = Math.cos(yawRad);
Vec3 candidate = new Vec3(
headPos.x + (dirX * distance),
headPos.y + yOffset,
headPos.z + (dirZ * distance)
);
if (activeParticles.isEmpty()) {
return candidate;
}
double minDistanceToOthers = Double.MAX_VALUE;
for (LyricParticle3D active : activeParticles) {
double d = candidate.distanceTo(active.getBasePosition());
if (d < minDistanceToOthers) {
minDistanceToOthers = d;
}
}
if (minDistanceToOthers >= 1.35) {
return candidate;
}
if (minDistanceToOthers > maxMinDistance) {
maxMinDistance = minDistanceToOthers;
bestPos = candidate;
}
}
return bestPos;
}
private void spawnLyricParticle(String text, long spawnTimeMs, long durationMs) {
if (mc.player == null || text == null || text.isBlank()) return;
if (mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return;
Camera camera = mc.gameRenderer.mainCamera();
Vec3 headPos = mc.player.getEyePosition();
float cameraYaw = camera.yRot();
int maxAllowed = maxLines.get();
while (activeParticles.size() >= maxAllowed) {
activeParticles.remove(0);
}
Vec3 spawnPos = findNonOverlappingSpawnPos(headPos, cameraYaw);
if (spawnPos == null) return;
activeParticles.add(new LyricParticle3D(text, spawnPos, spawnTimeMs, durationMs));
}
private TextRenderer getSelectedFont() {
return Fonts.renderer(fontMode.get().family, FontInfo.Type.Regular, TextRenderer.get());
}
private int getActiveColorRgb() {
return switch (colorMode.get()) {
case THEME -> Theme.theme().accent();
case THEME_GRADIENT -> {
float ratio = (float) (Math.sin(System.currentTimeMillis() / 450.0) * 0.5 + 0.5);
int c1 = Theme.theme().accent();
int c2 = Theme.theme().accentSoft();
yield mixArgb(c1, c2, ratio);
}
case WHITE -> 0xFFFFFF;
case STATIC -> staticColor.getArgb();
};
}
private static int mixArgb(int from, int to, float t) {
t = Math.max(0.0f, Math.min(1.0f, t));
int r = mixChannel((from >>> 16) & 0xFF, (to >>> 16) & 0xFF, t);
int g = mixChannel((from >>> 8) & 0xFF, (to >>> 8) & 0xFF, t);
int b = mixChannel(from & 0xFF, to & 0xFF, t);
return (r << 16) | (g << 8) | b;
}
private static int mixChannel(int from, int to, float t) {
return from + Math.round((to - from) * t);
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || mc.level == null || mc.player == null) return;
if (activeParticles.isEmpty()) return;
TextRenderer font = getSelectedFont();
String animMode = animation.get().name();
int colorRgb = getActiveColorRgb();
long now = System.currentTimeMillis();
for (LyricParticle3D particle : activeParticles) {
particle.render(renderer, font, colorRgb, animMode, now);
}
}
@Override
public void onRenderHudEngineForeground(Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx, float tickDelta) {
if (!isEnabled() || !debugMode.get() || mc.player == null) return;
TextRenderer tr = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, textRenderer);
String trackLine = "Track: " + (currentTrackTitle.isEmpty() ? "None" : currentTrackTitle + " - " + currentTrackArtist);
long displayTimeMs = Math.max(0L, lastAudioTimeMs);
String timerLine = String.format("Audio time: %02d:%02d.%03d (%d ms)",
displayTimeMs / 60_000,
(displayTimeMs % 60_000) / 1000,
displayTimeMs % 1000,
displayTimeMs
);
String particlesLine = "Active particles: " + activeParticles.size();
String queueLine = "Queue: " + currentLyricIndex + " / " + lyricsQueue.size() + " lines";
tr.begin(0.85);
tr.render(trackLine, 10.0, 70.0, new RenderColor(0xFFDCDCDC), false);
tr.render(timerLine, 10.0, 82.0, new RenderColor(0xFFFFFFFF), false);
tr.render(particlesLine, 10.0, 94.0, new RenderColor(0xFF78FF78), false);
tr.render(queueLine, 10.0, 106.0, new RenderColor(0xFFB4B4FF), false);
tr.end();
}
public enum AnimationMode implements EnumValue.IdProvider {
LYRIC_FLOW("lyric_flow"),
TYPEWRITER("typewriter"),
POP_SCALE("pop_scale"),
KINETIC_SLIDE("kinetic_slide"),
FADE("fade");
private final String id;
AnimationMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
public enum ColorMode implements EnumValue.IdProvider {
THEME("theme"),
THEME_GRADIENT("theme_gradient"),
WHITE("white"),
STATIC("static");
private final String id;
ColorMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
public enum FontMode implements EnumValue.IdProvider {
ONEST_BOLD("onest_bold", "OnestBold"),
ONEST_MEDIUM("onest_medium", "OnestMedium"),
ONEST("onest", "Onest"),
INTER_MEDIUM("inter_medium", "InterMedium");
private final String id;
private final String family;
FontMode(String id, String family) {
this.id = id;
this.family = family;
}
@Override
public String id() {
return id;
}
}
public enum SplitMode implements EnumValue.IdProvider {
SMART_SPLIT("smart_split"),
FULL_LINE("full_line");
private final String id;
SplitMode(String id) {
this.id = id;
}
@Override
public String id() {
return id;
}
}
}

View file

@ -0,0 +1,184 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.loki.lovisual.util.logging.DebugLog;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Fetches synced lyrics from lrclib.net (no API key required).
*/
public final class LrcLibClient {
private static final String API_GET = "https://lrclib.net/api/get";
private static final String API_SEARCH = "https://lrclib.net/api/search";
private static final String USER_AGENT = "LoVisual/1.0";
private static final Map<String, List<LyricLine>> LYRICS_CACHE = new ConcurrentHashMap<>();
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(12))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private LrcLibClient() {
}
public static CompletableFuture<List<LyricLine>> fetchLyricsAsync(String trackName, String artistName, boolean debugMode) {
if (trackName == null || trackName.isBlank()) {
return CompletableFuture.completedFuture(Collections.emptyList());
}
String cacheKey = (trackName.trim() + " - " + (artistName != null ? artistName.trim() : "")).toLowerCase();
List<LyricLine> cached = LYRICS_CACHE.get(cacheKey);
if (cached != null) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Cache hit: %s (%d lines)", cacheKey, cached.size());
}
return CompletableFuture.completedFuture(cached);
}
return CompletableFuture.supplyAsync(() -> {
List<LyricLine> lines = tryFetchGet(trackName, artistName, debugMode);
if (!lines.isEmpty()) {
LYRICS_CACHE.put(cacheKey, lines);
return lines;
}
lines = tryFetchSearch(trackName, artistName, debugMode);
if (!lines.isEmpty()) {
LYRICS_CACHE.put(cacheKey, lines);
return lines;
}
return Collections.<LyricLine>emptyList();
});
}
private static List<LyricLine> tryFetchGet(String trackName, String artistName, boolean debugMode) {
String cleanTrack = cleanTrackName(trackName);
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
String query = String.format("?track_name=%s&artist_name=%s",
URLEncoder.encode(cleanTrack, StandardCharsets.UTF_8),
URLEncoder.encode(cleanArtist, StandardCharsets.UTF_8));
return executeRequest(API_GET + query, debugMode);
}
private static List<LyricLine> tryFetchSearch(String trackName, String artistName, boolean debugMode) {
String cleanTrack = cleanTrackName(trackName);
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
String searchQuery = (cleanTrack + " " + cleanArtist).trim();
String query = "?q=" + URLEncoder.encode(searchQuery, StandardCharsets.UTF_8);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_SEARCH + query))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(14))
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonArray array = JsonParser.parseString(response.body()).getAsJsonArray();
for (JsonElement elem : array) {
if (elem.isJsonObject()) {
JsonObject obj = elem.getAsJsonObject();
if (obj.has("syncedLyrics") && !obj.get("syncedLyrics").isJsonNull()) {
String syncedLrc = obj.get("syncedLyrics").getAsString();
List<LyricLine> parsed = LrcParser.parse(syncedLrc);
if (!parsed.isEmpty()) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Found lyrics via search fallback");
}
return parsed;
}
}
}
}
} else if (response.statusCode() == 404) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Search: track not found (404)");
}
}
} catch (Exception e) {
if (debugMode) {
DebugLog.error("[KineticLyrics] Search fallback failed", e);
}
}
return Collections.emptyList();
}
private static List<LyricLine> executeRequest(String url, boolean debugMode) {
for (int attempt = 1; attempt <= 2; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(14))
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("syncedLyrics") && !json.get("syncedLyrics").isJsonNull()) {
String syncedLrc = json.get("syncedLyrics").getAsString();
return LrcParser.parse(syncedLrc);
}
if (debugMode) {
DebugLog.info("[KineticLyrics] No syncedLyrics in response");
}
return Collections.emptyList();
} else if (response.statusCode() == 404) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Track not found (404)");
}
return Collections.emptyList();
} else {
if (debugMode) {
DebugLog.info("[KineticLyrics] Attempt %d returned %d", attempt, response.statusCode());
}
}
} catch (Exception e) {
if (debugMode) {
DebugLog.error("[KineticLyrics] Attempt " + attempt + " failed", e);
}
if (attempt < 2) {
try {
Thread.sleep(400);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
}
}
return Collections.emptyList();
}
static String cleanTrackName(String trackName) {
if (trackName == null) return "";
return trackName
.replaceAll("\\(.*?\\)", "")
.replaceAll("\\[.*?\\]", "")
.trim();
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class LrcParser {
private static final Pattern LRC_PATTERN = Pattern.compile("\\[(\\d+):(\\d{2})(?:\\.(\\d{1,3}))?\\](.*)");
private LrcParser() {
}
public static List<LyricLine> parse(String lrcContent) {
if (lrcContent == null || lrcContent.isBlank()) {
return Collections.emptyList();
}
List<LyricLine> lines = new ArrayList<>();
String[] rawLines = lrcContent.split("\\r?\\n");
for (String line : rawLines) {
String trimmed = line.trim();
if (trimmed.isEmpty()) continue;
Matcher matcher = LRC_PATTERN.matcher(trimmed);
if (matcher.matches()) {
try {
long minutes = Long.parseLong(matcher.group(1));
long seconds = Long.parseLong(matcher.group(2));
String fractionStr = matcher.group(3);
long millis = 0;
if (fractionStr != null && !fractionStr.isEmpty()) {
millis = Long.parseLong(fractionStr);
if (fractionStr.length() == 1) {
millis *= 100;
} else if (fractionStr.length() == 2) {
millis *= 10;
}
}
long totalTimeMs = (minutes * 60_000L) + (seconds * 1_000L) + millis;
String text = matcher.group(4).trim();
if (!text.isEmpty()) {
lines.add(new LyricLine(totalTimeMs, text));
}
} catch (NumberFormatException ignored) {
}
}
}
Collections.sort(lines);
return lines;
}
}

View file

@ -0,0 +1,8 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
public record LyricLine(long timestampMs, String text) implements Comparable<LyricLine> {
@Override
public int compareTo(LyricLine o) {
return Long.compare(this.timestampMs, o.timestampMs);
}
}

View file

@ -0,0 +1,68 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class LyricLineSplitter {
private LyricLineSplitter() {
}
/**
* Splits long lyric lines (more than 4 words) into shorter rhythmic chunks with micro-timings.
*/
public static List<LyricLine> splitLongLines(List<LyricLine> originalLines) {
if (originalLines == null || originalLines.isEmpty()) {
return Collections.emptyList();
}
List<LyricLine> result = new ArrayList<>();
for (int i = 0; i < originalLines.size(); i++) {
LyricLine current = originalLines.get(i);
long nextTimeMs = (i + 1 < originalLines.size()) ? originalLines.get(i + 1).timestampMs() : current.timestampMs() + 4000L;
long lineDuration = Math.max(1500L, nextTimeMs - current.timestampMs());
String text = current.text().trim();
String[] words = text.split("\\s+");
if (words.length <= 4) {
result.add(current);
} else {
int wordsPerChunk = words.length > 7 ? 3 : 2;
List<String> chunks = new ArrayList<>();
StringBuilder sb = new StringBuilder();
int count = 0;
for (String word : words) {
if (!sb.isEmpty()) sb.append(" ");
sb.append(word);
count++;
if (count >= wordsPerChunk) {
chunks.add(sb.toString());
sb.setLength(0);
count = 0;
}
}
if (!sb.isEmpty()) {
chunks.add(sb.toString());
}
int numChunks = chunks.size();
if (numChunks == 0) {
result.add(current);
continue;
}
long chunkDuration = lineDuration / numChunks;
for (int c = 0; c < numChunks; c++) {
long chunkStartMs = current.timestampMs() + (c * chunkDuration);
result.add(new LyricLine(chunkStartMs, chunks.get(c)));
}
}
}
Collections.sort(result);
return result;
}
}

View file

@ -0,0 +1,147 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.text.TextRenderer;
import dev.loki.lovisual.render.engine.text.WorldTextRenderer;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
public class LyricParticle3D {
private static final double BASE_WORLD_SCALE = 0.0016;
private final String text;
private final Vec3 basePosition;
private final long spawnTimeMs;
private final long durationMs;
private boolean forceExpire = false;
public LyricParticle3D(String text, Vec3 basePosition, long spawnTimeMs, long durationMs) {
this.text = text;
this.basePosition = basePosition;
this.spawnTimeMs = spawnTimeMs;
this.durationMs = durationMs;
}
public Vec3 getBasePosition() {
return basePosition;
}
public void dismiss() {
this.forceExpire = true;
}
public boolean isDead(long nowMs) {
return forceExpire || (nowMs - spawnTimeMs >= durationMs);
}
/**
* Renders this lyric line as a camera-facing 3D billboard with a selectable entrance/exit animation.
*/
public void render(Renderer3D renderer, TextRenderer textRenderer, int colorRgb, String animMode, long nowMs) {
long elapsed = nowMs - spawnTimeMs;
if (elapsed < 0 || elapsed > durationMs || forceExpire) return;
float progress = Mth.clamp((float) elapsed / (float) durationMs, 0.0f, 1.0f);
float alpha = 1.0f;
double offsetY = 0.0;
float animScale = 1.0f;
String displayText = this.text;
switch (animMode) {
case "TYPEWRITER" -> {
if (progress < 0.30f) {
float typeProgress = progress / 0.30f;
int visibleChars = Math.min(this.text.length(), (int) Math.ceil(this.text.length() * typeProgress));
displayText = this.text.substring(0, Math.max(1, visibleChars));
alpha = Math.min(1.0f, progress / 0.08f);
offsetY = progress * 0.1;
} else if (progress < 0.75f) {
offsetY = progress * 0.1;
} else {
float eraseProgress = (progress - 0.75f) / 0.25f;
int remainingChars = Math.max(0, this.text.length() - (int) Math.floor(this.text.length() * eraseProgress));
displayText = this.text.substring(0, remainingChars);
alpha = 1.0f - (eraseProgress * 0.7f);
offsetY = progress * 0.1;
}
}
case "POP_SCALE" -> {
if (progress < 0.18f) {
float t = progress / 0.18f;
alpha = easeOutCubic(t);
animScale = 0.35f + 0.65f * easeOutBack(t);
} else if (progress >= 0.80f) {
float t = (progress - 0.80f) / 0.20f;
alpha = 1.0f - easeInCubic(t);
animScale = 1.0f - 0.05f * t;
}
}
case "KINETIC_SLIDE" -> {
if (progress < 0.20f) {
float t = progress / 0.20f;
alpha = easeOutCubic(t);
offsetY = -0.15 * (1.0f - easeOutCubic(t));
animScale = 0.92f + 0.08f * t;
} else if (progress >= 0.80f) {
float t = (progress - 0.80f) / 0.20f;
alpha = 1.0f - easeInCubic(t);
offsetY = 0.05 * easeInCubic(t);
animScale = 1.0f - 0.04f * t;
}
}
case "FADE" -> {
if (progress < 0.15f) {
alpha = progress / 0.15f;
} else if (progress >= 0.80f) {
alpha = 1.0f - ((progress - 0.80f) / 0.20f);
}
}
default -> { // LYRIC_FLOW
if (progress < 0.15f) {
float t = progress / 0.15f;
alpha = easeOutCubic(t);
offsetY = -0.06 * (1.0f - easeOutCubic(t));
animScale = 0.93f + 0.07f * easeOutBack(t);
} else if (progress >= 0.80f) {
float t = (progress - 0.80f) / 0.20f;
alpha = 1.0f - easeInCubic(t);
offsetY = 0.04 * easeInCubic(t);
animScale = 1.0f - 0.03f * t;
}
}
}
alpha = Mth.clamp(alpha, 0.0f, 1.0f);
if (alpha <= 0.001f || displayText == null || displayText.isBlank()) return;
int alphaInt = (int) (alpha * 255.0f);
RenderColor color = new RenderColor((alphaInt << 24) | (colorRgb & 0x00FFFFFF));
Vec3 anchor = basePosition.add(0.0, offsetY, 0.0);
WorldTextRenderer.Options options = WorldTextRenderer.Options.defaults()
.withColor(color)
.withCentered(true)
.withShadow(true)
.withWorldScale(BASE_WORLD_SCALE * animScale)
.withDepthMode(Renderer3D.DepthMode.MAIN);
WorldTextRenderer.drawBillboard(renderer, textRenderer, displayText, anchor, options);
}
private static float easeOutCubic(float t) {
return 1.0f - (float) Math.pow(1.0f - t, 3);
}
private static float easeInCubic(float t) {
return (float) Math.pow(t, 3);
}
private static float easeOutBack(float t) {
float c1 = 1.70158f;
float c3 = c1 + 1.0f;
return 1.0f + c3 * (float) Math.pow(t - 1.0f, 3) + c1 * (float) Math.pow(t - 1.0f, 2);
}
}

View file

@ -0,0 +1,163 @@
/*
* 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.module.modules.visuals.trollface;
import dev.loki.lovisual.config.values.ModeValue;
import dev.loki.lovisual.config.values.NumberValue;
import dev.loki.lovisual.features.module.Module;
import dev.loki.lovisual.features.module.ModuleCategory;
import dev.loki.lovisual.features.module.ModuleInfo;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.target.TargetManager;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
@ModuleInfo(
id = "trollface_mask",
displayName = "Trollface Mask",
category = ModuleCategory.VISUALS
)
public class TrollfaceMask extends Module {
private static TrollfaceMask instance;
private final Minecraft mc = Minecraft.getInstance();
public final ModeValue target = mode(
"trollfaceTarget", "Себя",
"Себя", "Друзей", "Всех"
);
public final ModeValue maskType = mode(
"trollfaceMaskType", "Style 1",
"Style 1", "Style 2", "Style 3",
"Style 4", "Style 5", "Style 6"
);
public final NumberValue<Float> size = num(
"trollfaceSize", "size", 1.0f, 0.3f, 3.0f
);
public final NumberValue<Float> alpha = num(
"trollfaceAlpha", "alpha", 1.0f, 0.1f, 1.0f
);
public TrollfaceMask() {
instance = this;
}
public static TrollfaceMask getInstance() {
return instance;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (mc.player == null || mc.level == null) return;
String targetMode = target.get();
boolean selfMode = "Себя".equals(targetMode);
LivingEntity currentTarget = TargetManager.getTarget();
boolean hasCrosshairTarget = currentTarget != null && currentTarget.isAlive();
if (!selfMode && !hasCrosshairTarget) return;
LivingEntity renderTarget;
boolean shouldRender;
switch (targetMode) {
case "Себя" -> {
renderTarget = mc.player;
shouldRender = true;
}
case "Друзей" -> {
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
renderTarget = currentTarget;
shouldRender = true;
}
case "Всех" -> {
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
renderTarget = currentTarget;
shouldRender = true;
}
default -> {
renderTarget = mc.player;
shouldRender = true;
}
}
if (!shouldRender || renderTarget == null) return;
Vec3 camPos = mc.gameRenderer.mainCamera().position();
double headX = renderTarget.getX();
double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72;
double headZ = renderTarget.getZ();
Vec3 headPos = new Vec3(headX, headY, headZ);
Vec3 viewDir = camPos.subtract(headPos).normalize();
if (viewDir.lengthSqr() < 1.0E-8) return;
Vec3 upVec = new Vec3(0, 1, 0);
Vec3 right = viewDir.cross(upVec).normalize();
if (right.lengthSqr() < 1.0E-8) {
right = new Vec3(1, 0, 0);
}
Vec3 actualUp = right.cross(viewDir).normalize();
float s = size.get();
float alpha = this.alpha.get();
int color = 0xFFFFFFFF;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
int a = (int) (alpha * 255);
Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75));
Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75));
Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75));
Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75));
int textureIndex = maskTypeIndex(maskType.get());
Identifier texId = switch (textureIndex) {
case 0 -> TextureStorage.TROLLFACE_1;
case 1 -> TextureStorage.TROLLFACE_2;
case 2 -> TextureStorage.TROLLFACE_3;
case 3 -> TextureStorage.TROLLFACE_4;
case 4 -> TextureStorage.TROLLFACE_5;
case 5 -> TextureStorage.TROLLFACE_6;
default -> TextureStorage.TROLLFACE_1;
};
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, texId, Renderer3D.DepthMode.NONE);
if (mesh == null) return;
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(r, g, b, a).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(r, g, b, a).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(r, g, b, a).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(r, g, b, a).next();
mesh.quad(i1, i2, i3, i4);
}
private static int maskTypeIndex(String maskType) {
if (maskType == null) return 0;
return switch (maskType) {
case "Style 1" -> 0;
case "Style 2" -> 1;
case "Style 3" -> 2;
case "Style 4" -> 3;
case "Style 5" -> 4;
case "Style 6" -> 5;
default -> 0;
};
}
}

View file

@ -26,6 +26,7 @@ import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.module.Modules;
import dev.loki.lovisual.features.module.modules.visuals.HoldMyItems;
import dev.loki.lovisual.features.module.modules.visuals.ViewModel;
@Environment(EnvType.CLIENT)
@ -83,6 +84,20 @@ public abstract class ItemInHandRendererMixin {
return player != null && (player.isInWater() || player.isInLava());
}
@ModifyVariable(
method = "submitArmWithItem",
at = @At("HEAD"),
argsOnly = true,
index = 7)
private float lovisual$holdMyItemsEquipCurve(float equipProgress, AbstractClientPlayer player, float tickDelta,
float pitch, InteractionHand hand) {
HoldMyItems hmi = Modules.get(HoldMyItems.class);
if (hmi == null || !hmi.isEnabled()) return equipProgress;
if (hand == InteractionHand.OFF_HAND && !hmi.offhandToo.get()) return equipProgress;
return hmi.shapeEquipProgress(equipProgress);
}
@Inject(
method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V",
at = @At(

View file

@ -51,7 +51,6 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin {
"dev.loki.lovisual.mixins.xaero.minimap.AbstractWaypointRenderProviderMixin"
);
private static final Set<String> OPTIONAL_XAERO_WORLDMAP_MIXINS = Set.of(
"dev.loki.lovisual.mixins.xaero.worldmap.WorldMapWaypointAccessor",
"dev.loki.lovisual.mixins.xaero.worldmap.WaypointRenderProviderMixin"
);
private static final Set<String> OPTIONAL_MORECULLING_MIXINS = Set.of(

View file

@ -189,21 +189,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
// FREECAM + INTERACT
// ===============================
// Reach (FREECAM)
Reach reachModule = Modules.get(Reach.class);
if (reachModule != null && reachModule.isEnabled()) {
EntityHitResult hit = reachModule.raycastEntities(
mc.player, start, dir
);
if (hit != null && hit.getEntity() instanceof LivingEntity target && target.isAlive()) {
mc.hitResult = hit;
mc.crosshairPickEntity = target;
ci.cancel();
return;
}
}
// Blocks (FREECAM)
double reach = mc.player.blockInteractionRange();
Vec3 end = start.add(dir.scale(reach));
@ -220,16 +205,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
ci.cancel();
return;
}
Reach reachModule = Modules.get(Reach.class);
if (reachModule != null && reachModule.isEnabled()) {
EntityHitResult entityHit = reachModule.raycastEntities(mc.player);
if (entityHit != null && entityHit.getEntity() instanceof LivingEntity target && target.isAlive()) {
mc.hitResult = entityHit;
mc.crosshairPickEntity = target;
ci.cancel();
}
}
}
@Inject(method = "pick", at = @At("RETURN"))

View file

@ -163,9 +163,5 @@ public class MultiPlayerGameModeMixin {
TargetManager.onAttack(target);
}
@Inject(method = "attack(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;)V",
at = @At("RETURN"))
private void lovisual$attributeSwapAfterClientAttack(Player player, Entity target, CallbackInfo ci) {
// AttributeSwap module removed; no post-attack shield break remains.
}
}

View file

@ -1,30 +0,0 @@
/*
* 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.mixins.xaero.worldmap;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.gen.Accessor;
import xaero.map.mods.gui.Waypoint;
@Pseudo
@Mixin(Waypoint.class)
public interface WorldMapWaypointAccessor {
@Accessor
void setX(int x);
@Accessor
void setY(int y);
@Accessor
void setZ(int z);
@Accessor
void setColor(int color);
}

View file

@ -42,6 +42,20 @@ public enum TextureStorage {
Identifier.fromNamespaceAndPath("lovisual", "textures/particles/p11.png"),
Identifier.fromNamespaceAndPath("lovisual", "textures/particles/p12.png")
};
public static final Identifier TROLLFACE_1 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_1.png");
public static final Identifier TROLLFACE_2 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_2.png");
public static final Identifier TROLLFACE_3 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_3.png");
public static final Identifier TROLLFACE_4 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_4.png");
public static final Identifier TROLLFACE_5 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_5.png");
public static final Identifier TROLLFACE_6 =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_6.png");
public static final Identifier TROLLFACE_CLASSIC =
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_classic.png");
public static final Identifier CAPTURE =
Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/capture.png");
@ -58,6 +72,13 @@ public enum TextureStorage {
tm.getTexture(FUNNEL_DISTORTION);
tm.getTexture(PARTICLE_STARS);
tm.getTexture(CAPTURE);
tm.getTexture(TROLLFACE_1);
tm.getTexture(TROLLFACE_2);
tm.getTexture(TROLLFACE_3);
tm.getTexture(TROLLFACE_4);
tm.getTexture(TROLLFACE_5);
tm.getTexture(TROLLFACE_6);
tm.getTexture(TROLLFACE_CLASSIC);
for (Identifier id : RANDOM_PARTICLES) {
tm.getTexture(id);
}

View file

@ -101,6 +101,7 @@ public enum LoVisualRenderPipelines {
public static final Identifier SHADER_POST_FX_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/post_fx.frag");
public static final Identifier SHADER_MOTION_BLUR_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/motion_blur.frag");
public static final Identifier SHADER_DEPTH_OF_FIELD_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/depth_of_field.frag");
public static final Identifier SHADER_WET_SURFACE_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/wet_surface.frag");
public static final Identifier SHADER_HEAT_FX_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/heat_fx.frag");
public static final Identifier SHADER_ESP_GRADIENT_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/shader_esp_gradient.frag");
public static final Identifier SHADER_ESP_SHADOW_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/shader_esp_shadow.frag");
@ -121,6 +122,9 @@ public enum LoVisualRenderPipelines {
public static final Identifier SHADER_SKY_SUN_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_sun.frag");
public static final Identifier SHADER_MENU_BACKGROUND_WAVES_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/menu_background_waves.frag");
public static final Identifier SHADER_MENU_BACKGROUND_AURORA_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/menu_background_aurora.frag");
public static final Identifier SHADER_SKY_SHADER_SKY_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_sky.frag");
public static final Identifier SHADER_SKY_SHADER_WATER_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_water.frag");
public static final Identifier SHADER_SKY_SHADER_CAUSTIC_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_caustic.frag");
private static final List<RenderPipeline> PIPELINES = new ArrayList<>();
public static final RenderPipeline GUI_TEXTURE_LOOKUP = add(new ExtendedRenderPipelineBuilder(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED_SNIPPET)
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/gui_texture_lookup"))
@ -735,6 +739,45 @@ public enum LoVisualRenderPipelines {
.withCull(false)
.build()
);
/**
* Fullscreen SkyShader passes (pos2). Sky/water/caustic share the same uniform layout.
*/
public static final RenderPipeline SKY_SHADER_SKY = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_sky"))
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(SHADER_SKY_SHADER_SKY_FRAG)
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
.withDepthWrite(false)
.withBlend(BlendFunction.TRANSLUCENT)
.withCull(false)
.build()
);
public static final RenderPipeline SKY_SHADER_WATER = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_water"))
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(SHADER_SKY_SHADER_WATER_FRAG)
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
.withDepthWrite(false)
.withBlend(BlendFunction.TRANSLUCENT)
.withCull(false)
.build()
);
public static final RenderPipeline SKY_SHADER_CAUSTIC = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_caustic"))
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(SHADER_SKY_SHADER_CAUSTIC_FRAG)
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
.withDepthWrite(false)
.withBlend(BlendFunction.TRANSLUCENT)
.withCull(false)
.build()
);
/**
* Fullscreen main menu background (pos2).
*/
@ -822,6 +865,23 @@ public enum LoVisualRenderPipelines {
.withCull(false)
.build()
);
/**
* Fullscreen wet-ground screen-space reflection pass (pos2).
*/
public static final RenderPipeline WET_SURFACE = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/wet_surface"))
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
.withFragmentShader(SHADER_WET_SURFACE_FRAG)
.withSampler("u_Texture")
.withSampler("u_MainDepth")
.withUniform("WetSurface", UniformType.UNIFORM_BUFFER)
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
.withDepthWrite(false)
.withBlend(BlendFunction.TRANSLUCENT)
.withCull(false)
.build()
);
/**
* Fullscreen heat distortion + vignette (pos2).
*/

View file

@ -0,0 +1,115 @@
/*
* 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.render.engine.postprocess;
import com.mojang.blaze3d.ProjectionType;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.MeshRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.SkyShaderUniforms;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import net.minecraft.util.Util;
import org.joml.Matrix4f;
/**
* Fullscreen SkyShader pass: sky / water / caustic procedural GLSL effect drawn behind the world,
* with a camera-direction-driven ray so it reads correctly regardless of look direction.
*/
public enum SkyShaderRenderer {
;
public enum Mode {
SKY, WATER, CAUSTIC
}
private static final Matrix4f IDENTITY = new Matrix4f();
private static ProjectionMatrixBuffer projection;
private static long startMillis = -1L;
public static void reset() {
startMillis = -1L;
}
public static void render(Minecraft mc, Mode mode, float speed, float scale, float intensity,
float alpha, int colorRgb) {
if (mc == null || mc.level == null || mc.player == null) return;
RenderTarget fb = mc.gameRenderer.mainRenderTarget();
if (fb == null) return;
int w = mc.getWindow().getWidth();
int h = mc.getWindow().getHeight();
if (w <= 0 || h <= 0) return;
FullScreenRenderer.ensureInit();
if (startMillis < 0) startMillis = Util.getMillis();
float time = (Util.getMillis() - startMillis) / 1000.0f;
float cr = ((colorRgb >>> 16) & 0xFF) / 255f;
float cg = ((colorRgb >>> 8) & 0xFF) / 255f;
float cb = (colorRgb & 0xFF) / 255f;
Camera cam = mc.gameRenderer.mainCamera();
float yawRad = (float) Math.toRadians(-cam.yRot());
float pitchRad = (float) Math.toRadians(cam.xRot());
float fov = (float) mc.options.fov().get().intValue();
SkyShaderUniforms.update(time, speed, scale, intensity, w, h, alpha, fov, cr, cg, cb, yawRad, pitchRad);
var mv = RenderSystem.getModelViewStack();
boolean pushedModelView = false;
GpuBufferSlice prevProj = RenderSystem.getProjectionMatrixBuffer();
ProjectionType prevProjType = RenderSystem.getProjectionType();
Matrix4f prevMeshProjection = MeshRenderer.projection();
boolean prevRendering3D = RenderState.rendering3D;
try {
mv.pushMatrix();
pushedModelView = true;
mv.identity();
if (projection == null) {
projection = new ProjectionMatrixBuffer("lovisual-sky-shader-projection");
}
Matrix4f proj = IDENTITY.identity();
RenderSystem.setProjectionMatrix(projection.getBuffer(proj), ProjectionType.PERSPECTIVE);
MeshRenderer.setProjection(proj);
RenderState.rendering3D = false;
RenderPipeline pipeline = switch (mode) {
case WATER -> LoVisualRenderPipelines.SKY_SHADER_WATER;
case CAUSTIC -> LoVisualRenderPipelines.SKY_SHADER_CAUSTIC;
default -> LoVisualRenderPipelines.SKY_SHADER_SKY;
};
FullScreenRenderer.begin("LoVisual Fullscreen Pass")
.attachment(fb)
.pipeline(pipeline)
.uniform("SkyShader", SkyShaderUniforms.get())
.end();
} finally {
if (pushedModelView) {
mv.popMatrix();
}
MeshRenderer.setProjection(prevMeshProjection);
RenderState.rendering3D = prevRendering3D;
if (prevProj != null && prevProjType != null) {
RenderSystem.setProjectionMatrix(prevProj, prevProjType);
}
}
}
}

View file

@ -0,0 +1,81 @@
/*
* 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.render.engine.uniform.impl;
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
public enum SkyShaderUniforms {
;
public static final int SIZE = new Std140SizeCalculator()
.putVec4()
.putVec4()
.putVec4()
.putVec4()
.get();
private static final Data DATA = new Data();
private static final String UNIFORM_NAME = "LoVisual - SkyShader UBO";
private static final int EXPECTED_WRITES_PER_FRAME = 4;
public static void update(float time, float speed, float scale, float intensity,
float width, float height, float alpha, float fov,
float colorR, float colorG, float colorB,
float cameraDirX, float cameraDirY) {
DATA.params0[0] = time;
DATA.params0[1] = speed;
DATA.params0[2] = scale;
DATA.params0[3] = intensity;
DATA.params1[0] = width;
DATA.params1[1] = height;
DATA.params1[2] = alpha;
DATA.params1[3] = fov;
DATA.color[0] = colorR;
DATA.color[1] = colorG;
DATA.color[2] = colorB;
DATA.color[3] = 0.0f;
DATA.cameraDir[0] = cameraDirX;
DATA.cameraDir[1] = cameraDirY;
DATA.cameraDir[2] = 0.0f;
DATA.cameraDir[3] = 0.0f;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
}
public static GpuBufferSlice get() {
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
}
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
private final float[] params0 = new float[4];
private final float[] params1 = new float[4];
private final float[] color = new float[4];
private final float[] cameraDir = new float[4];
@Override
public void write(java.nio.ByteBuffer buffer) {
Std140Builder.intoBuffer(buffer)
.putFloat(params0[0]).putFloat(params0[1]).putFloat(params0[2]).putFloat(params0[3])
.putFloat(params1[0]).putFloat(params1[1]).putFloat(params1[2]).putFloat(params1[3])
.putFloat(color[0]).putFloat(color[1]).putFloat(color[2]).putFloat(color[3])
.putFloat(cameraDir[0]).putFloat(cameraDir[1]).putFloat(cameraDir[2]).putFloat(cameraDir[3]);
}
@Override
public boolean equals(Object o) {
return false;
}
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.render.engine.uniform.impl;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import org.joml.Matrix4f;
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
public enum WetSurfaceUniforms {
;
public static final int SIZE = new Std140SizeCalculator()
.putMat4f()
.putMat4f()
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.putFloat().putFloat().putFloat().putFloat()
.get();
private static final Data DATA = new Data();
private static final String UNIFORM_NAME = "LoVisual - WetSurface UBO";
private static final int EXPECTED_WRITES_PER_FRAME = 2;
public static void update(Matrix4f projection,
Matrix4f invView,
float camX, float camY, float camZ,
float invW, float invH, float time, boolean mainDepthEnabled,
float wetness, float puddleCoverage, float reflectionStrength, float rippleStrength,
float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled) {
DATA.projection.set(projection);
DATA.invView.set(invView);
DATA.camX = camX;
DATA.camY = camY;
DATA.camZ = camZ;
DATA.invW = invW;
DATA.invH = invH;
DATA.time = time;
DATA.mainDepthEnabled = mainDepthEnabled ? 1.0f : 0.0f;
DATA.wetness = wetness;
DATA.puddleCoverage = puddleCoverage;
DATA.reflectionStrength = reflectionStrength;
DATA.rippleStrength = rippleStrength;
DATA.maxDistance = maxDistance;
DATA.reflectionSteps = reflectionSteps;
DATA.rainAmount = rainAmount;
DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
}
public static GpuBufferSlice get() {
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
}
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
private final Matrix4f projection = new Matrix4f();
private final Matrix4f invView = new Matrix4f();
private float camX, camY, camZ;
private float invW, invH, time, mainDepthEnabled;
private float wetness, puddleCoverage, reflectionStrength, rippleStrength;
private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled;
@Override
public void write(java.nio.ByteBuffer buffer) {
Std140Builder b = Std140Builder.intoBuffer(buffer);
b.putMat4f(projection);
b.putMat4f(invView);
b.putFloat(camX).putFloat(camY).putFloat(camZ).putFloat(0.0f);
b.putFloat(invW).putFloat(invH).putFloat(time).putFloat(mainDepthEnabled);
b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength);
b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled);
}
@Override
public boolean equals(Object o) {
return false;
}
}
}

View file

@ -15,7 +15,7 @@ package dev.loki.lovisual.util.combat;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.network.protocol.game.ServerboundInteractPacket;
import net.minecraft.network.protocol.game.ServerboundAttackPacket;
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
import net.minecraft.world.InteractionHand;
@ -155,7 +155,9 @@ public enum AttackUtil {
}
notifyLocalAttack(target);
mc.getConnection().send(new ServerboundInteractPacket(target.getId(), null, null, player.isShiftKeyDown()));
mc.getConnection().send(new ServerboundAttackPacket(target.getId()));
player.attack(target);
player.resetAttackStrengthTicker();
ResetAttackCooldown.resetAttackCooldown(player);
return true;
}

View file

@ -1,721 +0,0 @@
/*
* 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.util.combat;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.protocol.game.*;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.enchantment.Enchantment;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.events.EventHandler;
import dev.loki.lovisual.events.impl.GameTickEvent;
import dev.loki.lovisual.events.impl.PacketEvent;
import dev.loki.lovisual.util.network.BacktrackController;
import dev.loki.lovisual.util.player.NetworkStatsUtil;
import java.util.*;
/**
* Local model for Vulcan 2.9.7 Reach A/B.
* This does not change combat by itself; Reach/Hitbox should query planReach()
* and actual sent attacks are observed through the same attack -> movement split.
*/
public final class VulcanReachController {
public static final VulcanReachController INSTANCE = new VulcanReachController();
private static final double REACH_A_CONFIG_MAX = 3.03;
private static final double REACH_A_BODY_SUBTRACT = 0.52;
private static final double REACH_A_BUFFER_MAX = 3.0;
private static final double REACH_A_LATCH_RELEASE = REACH_A_BUFFER_MAX - 1.0;
private static final double REACH_A_DECAY = 0.035;
private static final double REACH_A_NO_HISTORY_DECAY = 0.025;
private static final double REACH_A_RECENT_SERVER_ALLOWANCE = 1.15;
private static final double REACH_A_LIQUID_ALLOWANCE = 0.1;
private static final double REACH_A_SANITY_CAP = 8.0;
private static final double REACH_B_CONFIG_MAX = 3.15;
private static final double REACH_B_BODY_SUBTRACT = 0.565;
private static final double REACH_B_BUFFER_MAX = 6.0;
private static final double REACH_B_LATCH_RELEASE = REACH_B_BUFFER_MAX - 1.0;
private static final double REACH_B_DECAY = 0.225;
private static final double REACH_B_BASE_EXTRA = 0.05;
private static final double REACH_B_HORIZONTAL_ALLOWANCE = 0.9;
private static final double REACH_B_VERTICAL_ALLOWANCE = 0.66;
private static final double REACH_B_MOVING_STATE_ALLOWANCE = 0.32;
private static final double REACH_B_FRESH_STATE_ALLOWANCE = 0.35;
private static final double REACH_B_STATE_COUNTER_SCALE = 0.125;
private static final double REACH_B_HIGH_LATENCY_ALLOWANCE = 1.25;
private static final double REACH_B_SANITY_CAP = 6.0;
private static final long SERVER_POSITION_WINDOW_MS = 1500L;
private static final long JOIN_WORLD_WINDOW_MS = 1500L;
private static final long HISTORY_MAX_AGE_MS = 700L;
private static final long HISTORY_STRICT_WINDOW_MS = 250L;
private static final int HISTORY_MAX_SAMPLES = 20;
private static final double BUFFER_EPSILON = 1.0E-6;
private static final double FAST_POST_HIT_MOVE_THRESHOLD = 0.18;
private static final double FAST_POST_HIT_APPROACH_ALLOWANCE = 0.45;
private final Minecraft mc = Minecraft.getInstance();
private final Map<UUID, ArrayDeque<HistorySample>> targetHistory = new HashMap<>();
private double reachABuffer;
private double reachBBuffer;
private int pendingAttackEntityId = -1;
private long pendingAttackMs;
private Vec3 pendingAttackPosition;
private long lastServerPositionMs = Long.MIN_VALUE;
private long lastJoinOrWorldMs = Long.MIN_VALUE;
private Vec3 lastSentPosition;
private double lastSentHorizontalDelta;
private double lastSentVerticalDelta;
private int movementStateTicks;
private boolean reachAClampLatched;
private boolean reachBClampLatched;
private VulcanReachController() {
}
public ReachPlan planReach(Entity target, double requestedReach) {
LocalPlayer player = mc.player;
if (player == null || target == null) {
return ReachPlan.empty(requestedReach);
}
CheckState state = buildState(player, target, false);
updateClampLatches();
boolean forceReachAClamp = reachAClampLatched && !state.reachAExempt;
boolean forceReachBClamp = reachBClampLatched && !state.reachBExempt;
double strictReachALimit = state.reachAExempt
? requestedReach
: reachAEdgeLimit(player, target, state.reachAAllowed);
double reachALimit = state.reachAExempt || (state.noReachAHistory && !reachAClampLatched)
? requestedReach
: strictReachALimit;
if (state.fastPostHitApproach) {
reachALimit = Math.min(requestedReach, reachALimit + FAST_POST_HIT_APPROACH_ALLOWANCE);
}
double reachBLimit = state.reachBExempt
? requestedReach
: reachBEdgeLimit(player, target, state.reachBAllowed);
boolean overB = !state.reachBExempt
&& requestedReach > reachBLimit;
boolean spendsA = !state.reachAExempt
&& !state.noReachAHistory
&& requestedReach > strictReachALimit
&& state.reachAMeasured < REACH_A_SANITY_CAP;
boolean spendsB = overB && state.reachBMeasured < REACH_B_SANITY_CAP;
double clamped = requestedReach;
if (forceReachAClamp) {
clamped = Math.min(clamped, strictReachALimit);
}
if (forceReachBClamp) {
clamped = Math.min(clamped, reachBLimit);
}
return new ReachPlan(
requestedReach,
Math.max(0.0, clamped),
Math.max(0.0, reachALimit),
Math.max(0.0, reachBLimit),
reachABuffer,
reachBBuffer,
state.reachAExempt,
state.reachBExempt,
state.noReachAHistory,
spendsA,
spendsB,
state.reason
);
}
public double clampReach(Entity target, double requestedReach) {
return planReach(target, requestedReach).allowedReach();
}
public HudSnapshot hudSnapshot(Entity target, double requestedReach) {
if (!(target instanceof Player)) {
return null;
}
ReachPlan plan = planReach(target, requestedReach);
boolean dirty = reachABuffer > 1.0E-4
|| reachBBuffer > 1.0E-4
|| reachAClampLatched
|| reachBClampLatched;
double displayReach = plan.allowedReach();
boolean clamped = displayReach + BUFFER_EPSILON < requestedReach;
int rawRemaining = Math.min(
longHitsRemaining(reachABuffer, REACH_A_BUFFER_MAX, reachAClampLatched),
longHitsRemaining(reachBBuffer, REACH_B_BUFFER_MAX, reachBClampLatched)
);
int remaining = clamped ? 0 : Math.max(1, rawRemaining);
return new HudSnapshot(displayReach, Math.max(0, remaining), dirty, plan.reason());
}
public HudSnapshot hudSnapshot(double requestedReach) {
boolean dirty = reachABuffer > 1.0E-4
|| reachBBuffer > 1.0E-4
|| reachAClampLatched
|| reachBClampLatched;
int rawRemaining = Math.min(
longHitsRemaining(reachABuffer, REACH_A_BUFFER_MAX, reachAClampLatched),
longHitsRemaining(reachBBuffer, REACH_B_BUFFER_MAX, reachBClampLatched)
);
int remaining = reachAClampLatched || reachBClampLatched ? 0 : Math.max(1, rawRemaining);
return new HudSnapshot(requestedReach, Math.max(0, remaining), dirty, "no_target");
}
public double getReachABuffer() {
return reachABuffer;
}
public double getReachBBuffer() {
return reachBBuffer;
}
public void clear() {
reachABuffer = 0.0;
reachBBuffer = 0.0;
pendingAttackEntityId = -1;
pendingAttackMs = 0L;
pendingAttackPosition = null;
lastSentPosition = null;
lastSentHorizontalDelta = 0.0;
lastSentVerticalDelta = 0.0;
movementStateTicks = 0;
reachAClampLatched = false;
reachBClampLatched = false;
targetHistory.clear();
}
@EventHandler
private void onGameTick(GameTickEvent event) {
LocalPlayer player = mc.player;
if (player == null || mc.level == null) {
clear();
return;
}
long now = System.currentTimeMillis();
for (Player other : mc.level.players()) {
if (other == player || !other.isAlive()) {
continue;
}
addHistorySample(other, now);
}
pruneHistory(now);
}
@EventHandler
private void onPacketSendPost(PacketEvent.SendPost event) {
if (event.getPacket() instanceof ServerboundInteractPacket packet) {
onAttackPacket(packet);
return;
}
if (event.getPacket() instanceof ServerboundMovePlayerPacket packet) {
updateSentMovement(packet);
evaluatePendingAttack();
}
}
@EventHandler
private void onPacketReceive(PacketEvent.Receive event) {
if (event.getPacket() instanceof ClientboundPlayerPositionPacket) {
lastServerPositionMs = System.currentTimeMillis();
return;
}
if (event.getPacket() instanceof ClientboundLoginPacket) {
clear();
lastJoinOrWorldMs = System.currentTimeMillis();
return;
}
if (event.getPacket() instanceof ClientboundRespawnPacket) {
pendingAttackEntityId = -1;
}
}
private void onAttackPacket(ServerboundInteractPacket packet) {
if (!isAttack(packet) || mc.level == null) {
return;
}
int entityId = packet.entityId();
Entity target = mc.level.getEntity(entityId);
if (!(target instanceof Player)) {
pendingAttackEntityId = -1;
return;
}
pendingAttackEntityId = entityId;
pendingAttackMs = System.currentTimeMillis();
pendingAttackPosition = mc.player != null ? mc.player.position() : null;
}
private void evaluatePendingAttack() {
if (pendingAttackEntityId < 0 || mc.level == null || mc.player == null) {
return;
}
Entity target = mc.level.getEntity(pendingAttackEntityId);
pendingAttackEntityId = -1;
if (!(target instanceof Player)) {
return;
}
CheckState state = buildState(mc.player, target, true);
applyReachA(state);
applyReachB(state);
pendingAttackPosition = null;
}
private void applyReachA(CheckState state) {
if (state.reachAExempt) {
decayABuffer(REACH_A_DECAY);
return;
}
if (state.noReachAHistory) {
decayABuffer(REACH_A_NO_HISTORY_DECAY);
return;
}
if (state.reachAMeasured > state.reachAAllowed && state.reachAMeasured < REACH_A_SANITY_CAP) {
reachABuffer = increaseLocalBuffer(reachABuffer, REACH_A_BUFFER_MAX);
} else {
decayABuffer(REACH_A_DECAY);
}
}
private void applyReachB(CheckState state) {
if (state.reachBExempt) {
decayBBuffer(REACH_B_DECAY);
return;
}
if (state.reachBMeasured > state.reachBAllowed && state.reachBMeasured < REACH_B_SANITY_CAP) {
reachBBuffer = increaseLocalBuffer(reachBBuffer, REACH_B_BUFFER_MAX);
} else {
decayBBuffer(REACH_B_DECAY);
}
}
private CheckState buildState(LocalPlayer player, Entity target, boolean evaluatingPendingAttack) {
if (!(target instanceof Player targetPlayer)) {
return CheckState.exempt("non_player");
}
boolean attackerVehicle = player.isPassenger();
boolean targetVehicle = targetPlayer.isPassenger();
boolean attackerBoat = attackerVehicle && isBoatLike(player.getVehicle());
boolean targetBoat = targetVehicle && isBoatLike(targetPlayer.getVehicle());
boolean creative = player.isCreative();
boolean liquid = player.isInWater() || player.isUnderWater() || player.isInLava();
boolean recentServer = isRecent(lastServerPositionMs, SERVER_POSITION_WINDOW_MS);
boolean recentJoinWorld = isRecent(lastJoinOrWorldMs, JOIN_WORLD_WINDOW_MS);
boolean knockback = hasKnockback(player.getMainHandItem());
boolean fastPostHitApproach = evaluatingPendingAttack && isFastPostHitApproach(player, targetPlayer);
boolean reachAExempt = creative || attackerVehicle || attackerBoat || targetVehicle || targetBoat || knockback;
boolean reachBExempt = creative || attackerVehicle || attackerBoat || targetVehicle || targetBoat || recentServer || recentJoinWorld;
double baseReach = getInteractionRange(player);
double reachAAllowed = baseReach + (REACH_A_CONFIG_MAX - 3.0);
if (recentServer) {
reachAAllowed += REACH_A_RECENT_SERVER_ALLOWANCE;
}
if (liquid) {
reachAAllowed += REACH_A_LIQUID_ALLOWANCE;
}
double reachBAllowed = baseReach + (REACH_B_CONFIG_MAX - 3.0) + REACH_B_BASE_EXTRA;
reachBAllowed += lastSentHorizontalDelta * REACH_B_HORIZONTAL_ALLOWANCE;
reachBAllowed += lastSentVerticalDelta * REACH_B_VERTICAL_ALLOWANCE;
if (lastSentHorizontalDelta > 1.0E-4 || lastSentVerticalDelta > 1.0E-4) {
reachBAllowed += REACH_B_MOVING_STATE_ALLOWANCE;
}
if (movementStateTicks < 3) {
reachBAllowed += REACH_B_FRESH_STATE_ALLOWANCE;
}
reachBAllowed += Math.min(4, movementStateTicks) * REACH_B_STATE_COUNTER_SCALE;
if (NetworkStatsUtil.getPing(mc) > 100) {
reachBAllowed += REACH_B_HIGH_LATENCY_ALLOWANCE;
}
double reachAMeasured = reachAMeasureDistance(player, targetPlayer);
double reachBMeasured = Math.max(0.0, player.position().distanceTo(targetPlayer.position()) - REACH_B_BODY_SUBTRACT);
boolean noHistory = reachAMeasured < 0.0;
String reason = resolveReason(reachAExempt, reachBExempt, noHistory, recentServer, recentJoinWorld, liquid, knockback);
return new CheckState(
reachAExempt,
reachBExempt,
noHistory,
reachAAllowed,
reachBAllowed,
reachAMeasured,
reachBMeasured,
fastPostHitApproach,
reason
);
}
private double reachAMeasureDistance(LocalPlayer player, Player target) {
if (shouldUseBacktrackHistory(target)) {
return reachAHistoryDistance(player, target);
}
Vec3 playerPos = player.position();
Vec3 targetPos = target.position();
double dx = playerPos.x - targetPos.x;
double dz = playerPos.z - targetPos.z;
return Math.max(0.0, Math.sqrt(dx * dx + dz * dz) - REACH_A_BODY_SUBTRACT);
}
private boolean shouldUseBacktrackHistory(Entity target) {
BacktrackController backtrack = BacktrackController.INSTANCE;
Entity backtrackTarget = backtrack.getTarget();
return backtrack.isEnabled()
&& backtrack.isLagging()
&& backtrackTarget != null
&& target != null
&& backtrackTarget.getId() == target.getId();
}
private double reachAHistoryDistance(LocalPlayer player, Player target) {
ArrayDeque<HistorySample> samples = targetHistory.get(target.getUUID());
if (samples == null || samples.isEmpty()) {
return -1.0;
}
long now = pendingAttackMs > 0L ? pendingAttackMs : System.currentTimeMillis();
Vec3 playerPos = player.position();
double best = Double.MAX_VALUE;
for (HistorySample sample : samples) {
long age = Math.abs(now - sample.timeMs());
if (age > HISTORY_STRICT_WINDOW_MS) {
continue;
}
double dx = playerPos.x - sample.pos().x;
double dz = playerPos.z - sample.pos().z;
best = Math.min(best, Math.sqrt(dx * dx + dz * dz) - REACH_A_BODY_SUBTRACT);
}
return best == Double.MAX_VALUE ? -1.0 : Math.max(0.0, best);
}
private double reachAEdgeLimit(LocalPlayer player, Entity target, double allowed) {
Vec3 from = player.getEyePosition(1.0F);
AABB box = target.getBoundingBox();
Vec3 center = box.getCenter();
double centerDistance = Math.sqrt(squaredHorizontalDistance(from.x, from.z, center.x, center.z));
double edgeDistance = horizontalBoxDistance(box, from);
double centerToEdgeReduction = Math.max(0.0, centerDistance - edgeDistance);
return Math.max(0.0, allowed + REACH_A_BODY_SUBTRACT - centerToEdgeReduction);
}
private double reachBEdgeLimit(LocalPlayer player, Entity target, double allowed) {
Vec3 from = player.getEyePosition(1.0F);
AABB box = target.getBoundingBox();
Vec3 center = box.getCenter();
double centerDistance = from.distanceTo(center);
double edgeDistance = Math.sqrt(boxDistanceSq(box, from));
double centerToEdgeReduction = Math.max(0.0, centerDistance - edgeDistance);
return Math.max(0.0, allowed + REACH_B_BODY_SUBTRACT - centerToEdgeReduction);
}
private double horizontalBoxDistance(AABB box, Vec3 point) {
double dx = 0.0;
if (point.x < box.minX) dx = box.minX - point.x;
else if (point.x > box.maxX) dx = point.x - box.maxX;
double dz = 0.0;
if (point.z < box.minZ) dz = box.minZ - point.z;
else if (point.z > box.maxZ) dz = point.z - box.maxZ;
return Math.sqrt(dx * dx + dz * dz);
}
private double boxDistanceSq(AABB box, Vec3 point) {
double dx = 0.0;
if (point.x < box.minX) dx = box.minX - point.x;
else if (point.x > box.maxX) dx = point.x - box.maxX;
double dy = 0.0;
if (point.y < box.minY) dy = box.minY - point.y;
else if (point.y > box.maxY) dy = point.y - box.maxY;
double dz = 0.0;
if (point.z < box.minZ) dz = box.minZ - point.z;
else if (point.z > box.maxZ) dz = point.z - box.maxZ;
return dx * dx + dy * dy + dz * dz;
}
private double squaredHorizontalDistance(double x1, double z1, double x2, double z2) {
double dx = x1 - x2;
double dz = z1 - z2;
return dx * dx + dz * dz;
}
private void updateSentMovement(ServerboundMovePlayerPacket packet) {
LocalPlayer player = mc.player;
if (player == null) {
return;
}
Vec3 current = new Vec3(
packet.getX(player.getX()),
packet.getY(player.getY()),
packet.getZ(player.getZ())
);
if (lastSentPosition == null) {
lastSentPosition = current;
lastSentHorizontalDelta = 0.0;
lastSentVerticalDelta = 0.0;
movementStateTicks = 0;
return;
}
double dx = current.x - lastSentPosition.x;
double dy = current.y - lastSentPosition.y;
double dz = current.z - lastSentPosition.z;
lastSentHorizontalDelta = Math.sqrt(dx * dx + dz * dz);
lastSentVerticalDelta = Math.abs(dy);
lastSentPosition = current;
if (lastSentHorizontalDelta > 1.0E-4 || lastSentVerticalDelta > 1.0E-4) {
movementStateTicks = Mth.clamp(movementStateTicks + 1, 0, 20);
} else {
movementStateTicks = 0;
}
}
private boolean isFastPostHitApproach(LocalPlayer player, Player target) {
if (pendingAttackPosition == null) {
return false;
}
Vec3 current = player.position();
double moved = Math.sqrt(squaredHorizontalDistance(
pendingAttackPosition.x,
pendingAttackPosition.z,
current.x,
current.z
));
if (moved < FAST_POST_HIT_MOVE_THRESHOLD) {
return false;
}
Vec3 targetCenter = target.getBoundingBox().getCenter();
double then = Math.sqrt(squaredHorizontalDistance(
pendingAttackPosition.x,
pendingAttackPosition.z,
targetCenter.x,
targetCenter.z
));
double now = Math.sqrt(squaredHorizontalDistance(
current.x,
current.z,
targetCenter.x,
targetCenter.z
));
return now + 0.03 < then;
}
private void addHistorySample(Player player, long now) {
ArrayDeque<HistorySample> samples = targetHistory.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>());
samples.addLast(new HistorySample(player.position(), now));
while (samples.size() > HISTORY_MAX_SAMPLES) {
samples.removeFirst();
}
}
private void pruneHistory(long now) {
Iterator<Map.Entry<UUID, ArrayDeque<HistorySample>>> iterator = targetHistory.entrySet().iterator();
while (iterator.hasNext()) {
ArrayDeque<HistorySample> samples = iterator.next().getValue();
while (!samples.isEmpty() && now - samples.peekFirst().timeMs() > HISTORY_MAX_AGE_MS) {
samples.removeFirst();
}
if (samples.isEmpty()) {
iterator.remove();
}
}
}
private double getInteractionRange(LocalPlayer player) {
try {
return player.getAttributeValue(Attributes.ENTITY_INTERACTION_RANGE);
} catch (Throwable ignored) {
return 3.0;
}
}
private boolean hasKnockback(ItemStack stack) {
if (stack == null || stack.isEmpty()) {
return false;
}
Holder<Enchantment> entry = getEnchantmentEntry(Enchantments.KNOCKBACK);
return entry != null && EnchantmentHelper.getItemEnchantmentLevel(entry, stack) > 0;
}
private Holder<Enchantment> getEnchantmentEntry(ResourceKey<Enchantment> key) {
if (mc.level == null) {
return null;
}
Registry<Enchantment> registry = mc.level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT);
Enchantment enchantment = registry.getValue(key);
return enchantment != null ? registry.wrapAsHolder(enchantment) : null;
}
private boolean isBoatLike(Entity entity) {
return entity != null && entity.getType().toString().toLowerCase().contains("boat");
}
private boolean isAttack(ServerboundInteractPacket packet) {
return packet.hand() == null && packet.location() == null;
}
private boolean isAtPreAlert(double buffer, double max) {
return buffer + 1.0 > max + BUFFER_EPSILON;
}
private void updateClampLatches() {
if (isAtPreAlert(reachABuffer, REACH_A_BUFFER_MAX)) {
reachAClampLatched = true;
} else if (reachABuffer <= REACH_A_LATCH_RELEASE) {
reachAClampLatched = false;
}
if (isAtPreAlert(reachBBuffer, REACH_B_BUFFER_MAX)) {
reachBClampLatched = true;
} else if (reachBBuffer <= REACH_B_LATCH_RELEASE) {
reachBClampLatched = false;
}
}
private boolean isRecent(long timestamp, long windowMs) {
return timestamp != Long.MIN_VALUE && System.currentTimeMillis() - timestamp <= windowMs;
}
private void decayABuffer(double amount) {
reachABuffer = Math.max(0.0, reachABuffer - amount);
}
private void decayBBuffer(double amount) {
reachBBuffer = Math.max(0.0, reachBBuffer - amount);
}
private double increaseLocalBuffer(double buffer, double max) {
return Math.min(max, buffer + 1.0);
}
private int longHitsRemaining(double buffer, double max, boolean latched) {
if (latched || isAtPreAlert(buffer, max)) {
return 0;
}
return Math.max(0, (int) Math.floor(max - buffer + BUFFER_EPSILON));
}
private String resolveReason(boolean reachAExempt,
boolean reachBExempt,
boolean noHistory,
boolean recentServer,
boolean recentJoinWorld,
boolean liquid,
boolean knockback) {
if (recentServer) return "server_position";
if (recentJoinWorld) return "join_world";
if (noHistory) return "no_history";
if (liquid) return "liquid";
if (knockback) return "knockback";
if (reachAExempt || reachBExempt) return "exempt";
return "normal";
}
public record ReachPlan(
double requestedReach,
double allowedReach,
double reachALimit,
double reachBLimit,
double reachABuffer,
double reachBBuffer,
boolean reachAExempt,
boolean reachBExempt,
boolean noReachAHistory,
boolean usesReachABuffer,
boolean usesReachBBuffer,
String reason
) {
private static ReachPlan empty(double requestedReach) {
return new ReachPlan(requestedReach, requestedReach, requestedReach, requestedReach,
0.0, 0.0, true, true, true, false, false, "empty");
}
}
public record HudSnapshot(
double displayReach,
int longHitsRemaining,
boolean dirty,
String reason
) {
}
private record HistorySample(Vec3 pos, long timeMs) {
}
private record CheckState(
boolean reachAExempt,
boolean reachBExempt,
boolean noReachAHistory,
double reachAAllowed,
double reachBAllowed,
double reachAMeasured,
double reachBMeasured,
boolean fastPostHitApproach,
String reason
) {
private static CheckState exempt(String reason) {
return new CheckState(true, true, true, 0.0, 0.0, -1.0, -1.0, false, reason);
}
}
}

View file

@ -588,6 +588,9 @@
"setting.targetesp.mode.option.ring": "Ring",
"setting.targetesp.mode.option.capture_mark": "Capture mark",
"setting.targetesp.mode.option.crystals": "Crystals",
"setting.targetesp.mode.option.blackhole": "Blackhole",
"setting.targetesp.mode.option.vortex": "Vortex",
"setting.targetesp.vortex_speed": "Vortex speed",
"setting.targetesp.color": "Color",
"setting.targetesp.color_mode": "Color mode",
"setting.targetesp.color_mode.option.Static": "Static",
@ -2820,6 +2823,8 @@
"setting.common.shulker_bg_radius": "Background radius",
"setting.common.shulker_bg_softness": "Background softness",
"setting.common.shulker_padding": "Padding",
"setting.common.tooltip_font": "Tooltip font",
"setting.common.tooltip_text_size": "Tooltip text size",
"setting.common.shulker_preview": "Shulker Preview",
"setting.common.shulker_preview_hold": "Preview hold",
"setting.common.shulker_slot_bg_color": "Slot background color",

View file

@ -523,6 +523,9 @@
"setting.targetesp.mode.option.ring": "Кольцо",
"setting.targetesp.mode.option.capture_mark": "Метка захвата",
"setting.targetesp.mode.option.crystals": "Кристаллы",
"setting.targetesp.mode.option.blackhole": "Чёрная дыра",
"setting.targetesp.mode.option.vortex": "Вихрь",
"setting.targetesp.vortex_speed": "Скорость вихря",
"setting.targetesp.color": "Цвет",
"setting.targetesp.color_mode": "Режим цвета",
"setting.targetesp.color_mode.option.Static": "Статичный",
@ -2619,6 +2622,8 @@
"setting.common.shulker_bg_radius": "Радиус фона",
"setting.common.shulker_bg_softness": "Мягкость фона",
"setting.common.shulker_padding": "Отступ",
"setting.common.tooltip_font": "Шрифт тултипа",
"setting.common.tooltip_text_size": "Размер текста тултипа",
"setting.common.shulker_preview": "Превью шалкера",
"setting.common.shulker_preview_hold": "Удержание превью",
"setting.common.shulker_slot_bg_color": "Цвет фона слота",

View file

@ -0,0 +1,91 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 pivosos2007.
*
* Portions of this file are based on, adapted from, or implemented
* with reference to a "SkyShader" module distributed for another
* Minecraft client. The original attribution was not preserved.
* This implementation has since been substantially modified for LoVisual.
*
* Licensed under the GNU General Public License v3.0.
*/
out vec4 fragColor;
layout (std140) uniform SkyShader {
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
vec4 u_Color; // rgb = base color
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
};
in vec2 v_TexCoord;
#define MAX_ITER 4
mat3 rotX(float a) {
float c = cos(a), s = sin(a);
return mat3(1.0, 0.0, 0.0,
0.0, c, s,
0.0, -s, c);
}
mat3 rotY(float a) {
float c = cos(a), s = sin(a);
return mat3( c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c);
}
void main() {
float time = u_Params0.x;
float speed = u_Params0.y;
float scale = u_Params0.z;
float intensity = u_Params0.w;
vec2 res = u_Params1.xy;
float alpha = u_Params1.z;
float fov = u_Params1.w;
vec3 baseColor = u_Color.rgb;
vec2 sp = v_TexCoord * 2.0 - 1.0;
float aspect = res.x / max(res.y, 1.0);
float tanV = tan(radians(fov) * 0.5);
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
vec3 p = rayW * scale;
vec3 i = p;
float c = 1.0;
float inten = intensity;
for (int n = 0; n < MAX_ITER; n++) {
float t = time * speed * (1.0 - (3.0 / float(n + 1)));
i = p + vec3(
cos(t - i.x) + sin(t + i.y),
sin(t - i.y) + cos(t + i.z),
cos(t - i.z) + sin(t + i.x)
);
c += 1.0 / length(vec3(
p.x / (sin(i.x + t) / inten),
p.y / (cos(i.y + t) / inten),
p.z / (sin(i.z + t) / inten)
));
}
c /= float(MAX_ITER);
c = 1.5 - sqrt(c);
float brightness = c * c * c * c;
// Sharper, more crystalline highlights than the original for a proper caustic look.
float sharp = pow(brightness, 1.4);
vec3 color = baseColor * sharp * 1.8 + baseColor * 0.15;
float vignette = smoothstep(1.4, 0.15, length(sp));
color *= mix(0.7, 1.0, vignette);
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
}

View file

@ -0,0 +1,94 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 pivosos2007.
*
* Portions of this file are based on, adapted from, or implemented
* with reference to a "SkyShader" module distributed for another
* Minecraft client. The original attribution was not preserved.
* This implementation has since been substantially modified for LoVisual.
*
* Licensed under the GNU General Public License v3.0.
*/
out vec4 fragColor;
layout (std140) uniform SkyShader {
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
vec4 u_Color; // rgb = base color
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
};
in vec2 v_TexCoord;
mat3 rotX(float a) {
float c = cos(a), s = sin(a);
return mat3(1.0, 0.0, 0.0,
0.0, c, s,
0.0, -s, c);
}
mat3 rotY(float a) {
float c = cos(a), s = sin(a);
return mat3( c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c);
}
float wave(vec3 dir, float phase, float scale, float speed, float time) {
return sin(dir.x * scale + time * speed + phase)
* sin(dir.z * scale * 0.7 - time * speed * 0.8 + phase * 1.7)
* sin(dir.y * scale * 1.3 + time * speed * 0.5 + phase * 2.3);
}
void main() {
float time = u_Params0.x;
float speed = u_Params0.y;
float scale = u_Params0.z;
float intensity = u_Params0.w;
vec2 res = u_Params1.xy;
float alpha = u_Params1.z;
float fov = u_Params1.w;
vec3 baseColor = u_Color.rgb;
vec2 sp = v_TexCoord * 2.0 - 1.0;
float aspect = res.x / max(res.y, 1.0);
float tanV = tan(radians(fov) * 0.5);
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
float h = clamp(rayW.y, -1.0, 1.0);
// Vertical atmosphere gradient with a warmer, higher-contrast horizon glow.
vec3 zenith = baseColor * 0.12;
vec3 horizon = baseColor * 0.95 + vec3(0.05, 0.03, 0.01);
vec3 base = mix(horizon, zenith, pow(clamp(h, 0.0, 1.0), 0.55));
base += baseColor * 0.4 * exp(-abs(h) * 4.5);
// Flowing aurora bands, seamless on the full sphere (world-space direction).
float bands = 0.0;
bands += wave(rayW, 0.0, scale, speed, time);
bands += wave(rayW * 1.9 + 13.7, 2.1, scale, speed, time) * 0.55;
bands += wave(rayW * 3.7 + 41.3, 4.7, scale, speed, time) * 0.3;
bands *= intensity * 40.0;
// Fade aurora towards the horizon so it hugs the upper sky, softened edge.
float auroraMask = smoothstep(-0.1, 0.5, h);
vec3 aurora = baseColor * bands * auroraMask;
// Subtle star shimmer near the zenith, gently pulsing.
float star = pow(max(sin(rayW.x * 220.0) * sin(rayW.y * 260.0) * sin(rayW.z * 240.0), 0.0), 26.0);
star *= smoothstep(0.2, 0.85, h) * (0.55 + 0.45 * sin(time * speed * 2.4));
vec3 color = base + aurora + vec3(star) * (0.6 + 0.4 * baseColor);
// Gentle vignette so the effect reads as sky rather than a flat overlay.
float vignette = smoothstep(1.35, 0.2, length(sp));
color *= mix(0.82, 1.0, vignette);
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
}

View file

@ -0,0 +1,91 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 pivosos2007.
*
* Portions of this file are based on, adapted from, or implemented
* with reference to a "SkyShader" module distributed for another
* Minecraft client. The original attribution was not preserved.
* This implementation has since been substantially modified for LoVisual.
*
* Licensed under the GNU General Public License v3.0.
*/
out vec4 fragColor;
layout (std140) uniform SkyShader {
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
vec4 u_Color; // rgb = base color
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
};
in vec2 v_TexCoord;
#define MAX_ITER 4
mat3 rotX(float a) {
float c = cos(a), s = sin(a);
return mat3(1.0, 0.0, 0.0,
0.0, c, s,
0.0, -s, c);
}
mat3 rotY(float a) {
float c = cos(a), s = sin(a);
return mat3( c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c);
}
void main() {
float time = u_Params0.x;
float speed = u_Params0.y;
float scale = u_Params0.z;
float intensity = u_Params0.w;
vec2 res = u_Params1.xy;
float alpha = u_Params1.z;
float fov = u_Params1.w;
vec3 baseColor = u_Color.rgb;
vec2 sp = v_TexCoord * 2.0 - 1.0;
float aspect = res.x / max(res.y, 1.0);
float tanV = tan(radians(fov) * 0.5);
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
// 3D noise from the world ray direction - seamless on the full sphere.
vec3 p = rayW * scale;
vec3 i = p;
float c = 1.0;
float inten = intensity;
for (int n = 0; n < MAX_ITER; n++) {
float t = time * speed * (11.0 - (3.0 / float(n + 1)));
i = p + vec3(
cos(t - i.x) + sin(t + i.y),
sin(t - i.y) + cos(t + i.z),
cos(t - i.z) + sin(t + i.x)
);
c += 1.0 / length(vec3(
p.x / (sin(i.x + t) / inten),
p.y / (cos(i.y + t) / inten),
p.z / (sin(i.z + t) / inten)
));
}
c /= float(MAX_ITER);
c = 1.5 - sqrt(c);
float brightness = c * c * c * c;
// Softer highlight rolloff and a deeper base tone for a more "liquid" feel.
vec3 color = baseColor * pow(brightness, 0.85) * 1.15 + baseColor * 0.18;
float vignette = smoothstep(1.4, 0.15, length(sp));
color *= mix(0.75, 1.0, vignette);
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
}

View file

@ -0,0 +1,174 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 pivosos2007.
*
* Licensed under the GNU General Public License v3.0.
*/
out vec4 color;
uniform sampler2D u_Texture;
uniform sampler2D u_MainDepth;
layout (std140) uniform WetSurface {
mat4 u_Projection;
mat4 u_InvView; // camera-space -> world-space rotation (camera-to-world)
vec4 u_CameraPos; // world-space camera position, w unused
vec4 u_Screen; // invW, invH, time, mainDepthEnabled
vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength
vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled
};
in vec2 v_TexCoord;
const float DEPTH_NEAR_EPS = 0.000001;
const float DEPTH_FAR_EPS = 0.999999;
bool validRawDepth(float d) {
return d == d && d > DEPTH_NEAR_EPS && d < DEPTH_FAR_EPS;
}
vec3 viewPositionFromDepth(vec2 uv, float rawDepth) {
vec4 clip = vec4(uv * 2.0 - 1.0, rawDepth, 1.0);
vec4 view = inverse(u_Projection) * clip;
return view.xyz / view.w;
}
bool projectToScreen(vec3 viewPos, out vec2 uv, out float rawDepth) {
vec4 clip = u_Projection * vec4(viewPos, 1.0);
if (clip.w <= 0.0001) return false;
vec3 ndc = clip.xyz / clip.w;
uv = ndc.xy * 0.5 + 0.5;
rawDepth = ndc.z;
return uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float valueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float puddleNoise(vec2 worldXZ) {
float n = valueNoise(worldXZ * 0.18) * 0.65
+ valueNoise(worldXZ * 0.45 + 17.0) * 0.35;
return n;
}
void main() {
vec2 uv = v_TexCoord;
vec4 base = texture(u_Texture, uv);
if (u_Screen.w < 0.5) {
color = base;
return;
}
float rawDepth = texture(u_MainDepth, uv).r;
if (!validRawDepth(rawDepth)) {
color = base;
return;
}
vec3 viewPos = viewPositionFromDepth(uv, rawDepth);
vec3 ddxPos = dFdx(viewPos);
vec3 ddyPos = dFdy(viewPos);
vec3 viewNormal = normalize(cross(ddxPos, ddyPos));
vec3 viewDir = normalize(viewPos);
if (dot(viewNormal, -viewDir) < 0.0) {
viewNormal = -viewNormal;
}
vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal);
float groundFactor = clamp(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
groundFactor = smoothstep(0.82, 0.98, groundFactor);
if (groundFactor <= 0.001) {
color = base;
return;
}
vec3 worldPos = u_CameraPos.xyz + (mat3(u_InvView) * viewPos);
float wetness = clamp(u_Params.x, 0.0, 2.0);
float puddleCoverage = clamp(u_Params.y, 0.0, 1.0);
float reflectionStrength = clamp(u_Params.z, 0.0, 2.0);
float rippleStrength = clamp(u_Params.w, 0.0, 2.0);
float maxDistance = max(u_Params2.x, 1.0);
int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 32.0));
float rainAmount = clamp(u_Params2.z, 0.0, 2.0);
bool ripplesEnabled = u_Params2.w > 0.5;
float noise = puddleNoise(worldPos.xz);
float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise);
wetMask *= groundFactor;
if (wetMask <= 0.003) {
color = base;
return;
}
vec3 reflNormal = viewNormal;
if (ripplesEnabled && rippleStrength > 0.001) {
float t = u_Screen.z;
float ripple = sin((worldPos.x + worldPos.z) * 3.1 + t * 2.4) * 0.5
+ sin((worldPos.x - worldPos.z * 1.7) * 4.3 - t * 3.1) * 0.5;
vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;
reflNormal = normalize(viewNormal + vec3(ripple, 0.0, ripple * 0.6) * rippleStrength * 0.05);
}
vec3 reflDir = reflect(viewDir, reflNormal);
vec3 reflectionColor = mix(vec3(0.45, 0.52, 0.62), vec3(0.75, 0.82, 0.92), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0));
bool hit = false;
float stepLen = maxDistance / float(steps);
vec3 marchPos = viewPos + reflNormal * 0.02;
for (int i = 0; i < 32; i++) {
if (i >= steps) break;
marchPos += reflDir * stepLen;
vec2 sampleUv;
float sampleClipDepth;
if (!projectToScreen(marchPos, sampleUv, sampleClipDepth)) break;
float sceneRawDepth = texture(u_MainDepth, sampleUv).r;
if (!validRawDepth(sceneRawDepth)) continue;
vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth);
float marchDist = length(marchPos);
float sceneDist = length(scenePos);
float thickness = max(0.15, marchDist * 0.03);
if (marchDist > sceneDist && marchDist - sceneDist < thickness) {
reflectionColor = texture(u_Texture, sampleUv).rgb;
hit = true;
break;
}
}
float fresnel = pow(1.0 - clamp(dot(-viewDir, viewNormal), 0.0, 1.0), 3.0);
fresnel = mix(0.25, 1.0, fresnel);
float hitBoost = hit ? 1.0 : 0.55;
float reflectionAmount = wetMask * reflectionStrength * fresnel * hitBoost
* mix(0.6, 1.0, clamp(rainAmount, 0.0, 1.0)) * wetness;
reflectionAmount = clamp(reflectionAmount, 0.0, 1.0);
vec3 darkened = base.rgb * mix(1.0, 0.82, wetMask * clamp(wetness, 0.0, 1.0));
vec3 result = mix(darkened, reflectionColor, reflectionAmount);
color = vec4(clamp(result, 0.0, 1.0), 1.0);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -177,8 +177,7 @@
"sodium.SodiumVertexConsumerTrackerMixin",
"sodium.SodiumWorldRendererFreecamMixin",
"xaero.minimap.AbstractWaypointRenderProviderMixin",
"xaero.worldmap.WaypointRenderProviderMixin",
"xaero.worldmap.WorldMapWaypointAccessor"
"xaero.worldmap.WaypointRenderProviderMixin"
],
"injectors": {
"defaultRequire": 1

View file

@ -0,0 +1,57 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class LrcLibClientTest {
@Test
void cleanTrackNameRemovesParenthetical() {
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (feat. Guest)"));
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title (Remix)"));
assertEquals("", LrcLibClient.cleanTrackName("(Song)"));
}
@Test
void cleanTrackNameRemovesBrackets() {
assertEquals("Song Title", LrcLibClient.cleanTrackName("Song Title [Deluxe]"));
assertEquals("", LrcLibClient.cleanTrackName("[Song]"));
}
@Test
void cleanTrackNameHandlesNull() {
assertEquals("", LrcLibClient.cleanTrackName(null));
}
@Test
void cleanTrackNameHandlesEmpty() {
assertEquals("", LrcLibClient.cleanTrackName(""));
}
@Test
void cleanTrackNameHandlesWhitespaceOnly() {
assertEquals("", LrcLibClient.cleanTrackName(" "));
}
@Test
void cleanTrackNameTrims() {
assertEquals("Song Title", LrcLibClient.cleanTrackName(" Song Title "));
}
@Test
void fetchLyricsAsyncReturnsEmptyForBlankTrack() {
var future = LrcLibClient.fetchLyricsAsync("", "Artist", false);
List<LyricLine> result = future.join();
assertTrue(result.isEmpty());
}
@Test
void fetchLyricsAsyncReturnsEmptyForNullTrack() {
var future = LrcLibClient.fetchLyricsAsync(null, "Artist", false);
List<LyricLine> result = future.join();
assertTrue(result.isEmpty());
}
}

View file

@ -0,0 +1,136 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class LrcParserTest {
@Test
void parsesStandardLrcFormat() {
String lrc = "[00:12.00]Hello world\n[00:15.00]Second line";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(2, lines.size());
assertEquals(12000L, lines.get(0).timestampMs());
assertEquals("Hello world", lines.get(0).text());
assertEquals(15000L, lines.get(1).timestampMs());
assertEquals("Second line", lines.get(1).text());
}
@Test
void parsesSingleDigitMinutes() {
String lrc = "[1:05.20]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(65200L, lines.get(0).timestampMs());
}
@Test
void parsesWithoutFractionalSeconds() {
String lrc = "[01:30]No fraction";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(90000L, lines.get(0).timestampMs());
assertEquals("No fraction", lines.get(0).text());
}
@Test
void parsesOneDigitCentisecond() {
String lrc = "[00:05.1]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5100L, lines.get(0).timestampMs());
}
@Test
void parsesTwoDigitCentisecond() {
String lrc = "[00:05.12]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5120L, lines.get(0).timestampMs());
}
@Test
void parsesThreeDigitCentisecond() {
String lrc = "[00:05.123]Test";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals(5123L, lines.get(0).timestampMs());
}
@Test
void ignoresLinesWithoutTimestamp() {
String lrc = "Just a plain line\n[00:01.00]Has timestamp";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals("Has timestamp", lines.get(0).text());
}
@Test
void ignoresEmptyLines() {
String lrc = "\n\n[00:01.00]Line\n\n";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
}
@Test
void returnsEmptyForNullInput() {
assertTrue(LrcParser.parse(null).isEmpty());
}
@Test
void returnsEmptyForBlankInput() {
assertTrue(LrcParser.parse("").isEmpty());
assertTrue(LrcParser.parse(" ").isEmpty());
}
@Test
void returnsEmptyWhenNoSyncedLyrics() {
String lrc = "Some text without timestamp format";
List<LyricLine> lines = LrcParser.parse(lrc);
assertTrue(lines.isEmpty());
}
@Test
void sortsLinesByTimestamp() {
String lrc = "[00:30.00]Third\n[00:05.00]First\n[00:15.00]Second";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(3, lines.size());
assertEquals("First", lines.get(0).text());
assertEquals("Second", lines.get(1).text());
assertEquals("Third", lines.get(2).text());
}
@Test
void stripsWhitespaceFromText() {
String lrc = "[00:01.00] Trim me ";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(1, lines.size());
assertEquals("Trim me", lines.get(0).text());
}
@Test
void handlesMultipleLinesWithMixedFormats() {
String lrc = "[0:15.5]Short\n[01:30]No fraction\n[00:05.12]Two digits\n[00:05.123]Three digits";
List<LyricLine> lines = LrcParser.parse(lrc);
assertEquals(4, lines.size());
assertEquals(5120L, lines.get(0).timestampMs());
assertEquals(5123L, lines.get(1).timestampMs());
assertEquals(15500L, lines.get(2).timestampMs());
assertEquals(90000L, lines.get(3).timestampMs());
}
}

View file

@ -0,0 +1,99 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class LyricLineSplitterTest {
@Test
void returnsEmptyForNullInput() {
assertTrue(LyricLineSplitter.splitLongLines(null).isEmpty());
}
@Test
void returnsEmptyForEmptyList() {
assertTrue(LyricLineSplitter.splitLongLines(Collections.emptyList()).isEmpty());
}
@Test
void keepsShortLinesUnsplit() {
LyricLine line = new LyricLine(1000L, "Short text");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertEquals(1, result.size());
assertEquals(1000L, result.get(0).timestampMs());
assertEquals("Short text", result.get(0).text());
}
@Test
void splitsLongLineIntoChunks() {
LyricLine line = new LyricLine(2000L, "This is a long line with many words in it");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "Long line should be split into multiple chunks");
assertEquals("This is a", result.get(0).text());
}
@Test
void maintainsTimestampOrder() {
LyricLine line = new LyricLine(3000L, "One two three four five six seven eight nine ten");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() >= result.get(i - 1).timestampMs(),
"Timestamps should be in order");
}
}
@Test
void sortsResultByTimestamp() {
LyricLine line1 = new LyricLine(3000L, "Word word word word word word");
LyricLine line2 = new LyricLine(1000L, "Another line with several words here");
List<LyricLine> input = Arrays.asList(line1, line2);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() >= result.get(i - 1).timestampMs(),
"Result should be sorted by timestamp");
}
}
@Test
void splitsLineIntoMultipleChunksWithDecreasingDurations() {
LyricLine line = new LyricLine(5000L, "This line has too many words in it to be short");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "Long line should be split");
for (int i = 1; i < result.size(); i++) {
assertTrue(result.get(i).timestampMs() > result.get(i - 1).timestampMs(),
"Timestamps should be strictly increasing");
}
}
@Test
void handlesExactFourWordLine() {
LyricLine line = new LyricLine(1000L, "One two three four");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertEquals(1, result.size(), "Exactly 4 words should not be split");
}
@Test
void splitsFiveWordLine() {
LyricLine line = new LyricLine(1000L, "One two three four five");
List<LyricLine> input = Collections.singletonList(line);
List<LyricLine> result = LyricLineSplitter.splitLongLines(input);
assertTrue(result.size() > 1, "5 words should be split");
}
}