diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/admins.json b/combatant-client-26.2/config/lovisual/hud/draggable/admins.json new file mode 100644 index 0000000..458676e --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/admins.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/armor.json b/combatant-client-26.2/config/lovisual/hud/draggable/armor.json new file mode 100644 index 0000000..a4ee59f --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/armor.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/better_chat.json b/combatant-client-26.2/config/lovisual/hud/draggable/better_chat.json new file mode 100644 index 0000000..06fa1a2 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/better_chat.json @@ -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 + } +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/fps.json b/combatant-client-26.2/config/lovisual/hud/draggable/fps.json new file mode 100644 index 0000000..40704de --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/fps.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/game_time.json b/combatant-client-26.2/config/lovisual/hud/draggable/game_time.json new file mode 100644 index 0000000..8876e66 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/game_time.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/hud_notifier.json b/combatant-client-26.2/config/lovisual/hud/draggable/hud_notifier.json new file mode 100644 index 0000000..a79a71c --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/hud_notifier.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/inventory.json b/combatant-client-26.2/config/lovisual/hud/draggable/inventory.json new file mode 100644 index 0000000..7e726dc --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/inventory.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/itemizer.json b/combatant-client-26.2/config/lovisual/hud/draggable/itemizer.json new file mode 100644 index 0000000..e9b105f --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/itemizer.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/keybinds.json b/combatant-client-26.2/config/lovisual/hud/draggable/keybinds.json new file mode 100644 index 0000000..d893bd3 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/keybinds.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/media_player.json b/combatant-client-26.2/config/lovisual/hud/draggable/media_player.json new file mode 100644 index 0000000..464b2d6 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/media_player.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/memory.json b/combatant-client-26.2/config/lovisual/hud/draggable/memory.json new file mode 100644 index 0000000..4354390 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/memory.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/module_list.json b/combatant-client-26.2/config/lovisual/hud/draggable/module_list.json new file mode 100644 index 0000000..2dc8dfd --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/module_list.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/ping.json b/combatant-client-26.2/config/lovisual/hud/draggable/ping.json new file mode 100644 index 0000000..759a3a5 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/ping.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/potions.json b/combatant-client-26.2/config/lovisual/hud/draggable/potions.json new file mode 100644 index 0000000..63c9332 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/potions.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/radar.json b/combatant-client-26.2/config/lovisual/hud/draggable/radar.json new file mode 100644 index 0000000..9bc5b3e --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/radar.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/scoreboard.json b/combatant-client-26.2/config/lovisual/hud/draggable/scoreboard.json new file mode 100644 index 0000000..8356f0d --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/scoreboard.json @@ -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 + } +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/speed_bps.json b/combatant-client-26.2/config/lovisual/hud/draggable/speed_bps.json new file mode 100644 index 0000000..8bb89c0 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/speed_bps.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/system_time.json b/combatant-client-26.2/config/lovisual/hud/draggable/system_time.json new file mode 100644 index 0000000..5637c67 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/system_time.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/target_hud.json b/combatant-client-26.2/config/lovisual/hud/draggable/target_hud.json new file mode 100644 index 0000000..b28151d --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/target_hud.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/timer_indicator.json b/combatant-client-26.2/config/lovisual/hud/draggable/timer_indicator.json new file mode 100644 index 0000000..237e18e --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/timer_indicator.json @@ -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" +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/tps.json b/combatant-client-26.2/config/lovisual/hud/draggable/tps.json new file mode 100644 index 0000000..4e0dec9 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/tps.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/triangulator.json b/combatant-client-26.2/config/lovisual/hud/draggable/triangulator.json new file mode 100644 index 0000000..7a202d7 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/triangulator.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/draggable/xyz.json b/combatant-client-26.2/config/lovisual/hud/draggable/xyz.json new file mode 100644 index 0000000..25d7d5d --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/draggable/xyz.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_dynamic_island.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_dynamic_island.json new file mode 100644 index 0000000..7e3a186 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_dynamic_island.json @@ -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" +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_swap_tooltip.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_swap_tooltip.json new file mode 100644 index 0000000..d1970f6 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_swap_tooltip.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_tab_list.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_tab_list.json new file mode 100644 index 0000000..3e44f56 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_tab_list.json @@ -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" +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_bar.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_bar.json new file mode 100644 index 0000000..0afcb10 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_bar.json @@ -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" +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_buttons.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_buttons.json new file mode 100644 index 0000000..fc9af17 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_buttons.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_health.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_health.json new file mode 100644 index 0000000..b9b489b --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_health.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_hotbar.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_hotbar.json new file mode 100644 index 0000000..1390e4a --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_hotbar.json @@ -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 +} diff --git a/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json new file mode 100644 index 0000000..4ce8e27 --- /dev/null +++ b/combatant-client-26.2/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json @@ -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" +} diff --git a/combatant-client-26.2/config/lovisual/mainconfig.json5 b/combatant-client-26.2/config/lovisual/mainconfig.json5 new file mode 100644 index 0000000..fdf4c2c --- /dev/null +++ b/combatant-client-26.2/config/lovisual/mainconfig.json5 @@ -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': [ + ], +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java index 1fe3b4a..7c0eccf 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/LoVisual.java @@ -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); diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticAttachmentTransforms.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticAttachmentTransforms.java new file mode 100644 index 0000000..72acb1f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticAttachmentTransforms.java @@ -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); + } + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBbAnimation.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBbAnimation.java new file mode 100644 index 0000000..8468df6 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBbAnimation.java @@ -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 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 getTracks() { + return tracksByBoneUuid; + } + + public static Map parseAll(JsonObject bbmodel) { + Map 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 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 filter = model.getAnimationBoneFilter(); + for (Map.Entry 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 rotations = new ArrayList<>(); + private final List 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 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) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBone.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBone.java new file mode 100644 index 0000000..5a17efe --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticBone.java @@ -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 cubes = new ArrayList<>(); + private final List 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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticCube.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticCube.java new file mode 100644 index 0000000..ca1a35c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticCube.java @@ -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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticManager.java new file mode 100644 index 0000000..f2053e8 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticManager.java @@ -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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticModel.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticModel.java new file mode 100644 index 0000000..168bae4 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticModel.java @@ -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 rootBones = new ArrayList<>(); + private final List renderRoots = new ArrayList<>(); + private boolean customLegsPresent; + private boolean bowReplacementAccessory; + private boolean swordReplacementAccessory; + private boolean hatOnlyAccessory; + private boolean wingsOnlyAccessory; + private Set visibleBoneUuids; + @Setter private Set animationBoneFilter; + private final Map boneMap = new HashMap<>(); + private final Map boneMapByUuid = new HashMap<>(); + private final Map bbAnimations = new LinkedHashMap<>(); + private final List 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 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 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 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 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 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 uuids = new HashSet<>(); + pet.collectUuids(root, uuids); + pet.animationBoneFilter = uuids; + + for (Map.Entry 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 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 buildWingsBoneChainUuids() { + Set 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 uuids) { + uuids.add(bone.getUuid()); + for (CosmeticBone child : bone.getChildren()) { + collectBoneTreeUuids(child, uuids); + } + } + + private static boolean markBonePathToUuid(CosmeticBone bone, String targetUuid, Set 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 buildGroupMap(JsonObject bbmodel) { + Map 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 elementMap, + Map 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRenderer.java new file mode 100644 index 0000000..58c42da --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRenderer.java @@ -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 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> 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> 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> 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> 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> 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]; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRepository.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRepository.java new file mode 100644 index 0000000..1ddf345 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticRepository.java @@ -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/<type>/<name>} (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 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 all() { + return new ArrayList<>(catalog.values()); + } + + public List 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 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(); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticTextureSlot.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticTextureSlot.java new file mode 100644 index 0000000..ef6d390 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticTextureSlot.java @@ -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/<file>, 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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticType.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticType.java new file mode 100644 index 0000000..8c632de --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/cosmetic/CosmeticType.java @@ -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; + }; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiInputHandler.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiInputHandler.java index d269d1b..e59310f 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiInputHandler.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiInputHandler.java @@ -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; } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyBindManager.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyBindManager.java index 70f7493..fbead76 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyBindManager.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyBindManager.java @@ -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 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 keys) { diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyboardHandler.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyboardHandler.java index 3a8e6e9..07b7743 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyboardHandler.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/clickgui/ClickGuiKeyboardHandler.java @@ -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; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterTooltips.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterTooltips.java index e1f78cf..2bc3387 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterTooltips.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/BetterTooltips.java @@ -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 tooltipTextSize = + num("tooltip_text_size", 1.0f, 0.5f, 2.0f); private final ItemIdSetValue topIgnore = registerTopIgnore(); private final NumberValue 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 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) { diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomBar.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomBar.java index 230b6fb..695d589 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomBar.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomBar.java @@ -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 overlayAlpha = new NumberValue<>("overlay_alpha", 255, 30, 255); diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHealthBar.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHealthBar.java index 13117b4..8cb4260 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHealthBar.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHealthBar.java @@ -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; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHotbar.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHotbar.java index a6ec7ef..b9cb723 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHotbar.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/CustomHotbar.java @@ -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; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/DynamicIsland.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/DynamicIsland.java index aef9505..566ec56 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/DynamicIsland.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/DynamicIsland.java @@ -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; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Reach.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Reach.java deleted file mode 100644 index ab4afd5..0000000 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/Reach.java +++ /dev/null @@ -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 raycast = - enumCommon( - "reachContext", - "raycast", - CommonSettingSchemas.COMBAT_RAYCAST, - RaycastMode.STRICT, - RaycastMode.values() - ); - private final Minecraft mc = Minecraft.getInstance(); - - private final EnumValue mode = - enumCommon( - "reachMode", - "mode", - CommonSettingSchemas.ANTICHEAT_MODE, - Mode.NORMAL, - Mode.values() - ); - - private final NumberValue distance = - numCommon( - "reachDistance", - "range", - CommonSettingSchemas.COMBAT_RANGE, - 4.5, - 3.0, - 6.0 - ); - private final NumberValue 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 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 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 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 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 - } -} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/TargetESP.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/TargetESP.java index e0fa413..bb486e9 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/TargetESP.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/combat/TargetESP.java @@ -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 vortexSpeed = + visibleWhen(num("targetEspVortexSpeed", SETTING_VORTEX_SPEED, 1.2f, 0.2f, 4.0f), this::isVortexMode); private final List 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 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()); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BlockESP.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BlockESP.java new file mode 100644 index 0000000..ac3a08e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/BlockESP.java @@ -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 range = + num("range", 64, 8, 160); + + private final NumberValue 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 = + enumMode("color_mode", ColorMode.PER_BLOCK, ColorMode.values()); + + private final NumberValue colorSpeed = + visibleWhen(num("color_speed", 18, 2, 54), () -> colorMode.get() != ColorMode.PER_BLOCK && colorMode.get() != ColorMode.STATIC); + + private final Timer scanTimer = new Timer(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "BlockESP-Scanner"); + thread.setDaemon(true); + return thread; + }); + private final AtomicBoolean scanning = new AtomicBoolean(false); + private final AtomicBoolean scanInProgress = new AtomicBoolean(false); + private volatile List foundBlocks = new ArrayList<>(); + + 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 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 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/CosmeticDisplay.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/CosmeticDisplay.java new file mode 100644 index 0000000..4490b5f --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/CosmeticDisplay.java @@ -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 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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Crosshair.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Crosshair.java index 42edb5c..6609295 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Crosshair.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Crosshair.java @@ -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 = 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 = - enumSetting("crosshairVulcanReachDisplay", "vulcan_reach_display", - VulcanReachDisplay.TIMED, VulcanReachDisplay.values()); - private final NumberValue vulcanReachLookMs = - visibleWhen(num("crosshairVulcanReachLookMs", "vulcan_reach_look_ms", 1200, 0, 3000), - () -> vulcanReachDisplay.get() == VulcanReachDisplay.TIMED); - private final NumberValue 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, diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/HoldMyItems.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/HoldMyItems.java new file mode 100644 index 0000000..c64aaa3 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/HoldMyItems.java @@ -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 swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f); + public final NumberValue 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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/InvisEsp.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/InvisEsp.java index 2c75e68..10e7f33 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/InvisEsp.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/InvisEsp.java @@ -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 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); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/LightningTargetESP.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/LightningTargetESP.java new file mode 100644 index 0000000..1a64e11 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/LightningTargetESP.java @@ -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 lightningCount = num("lightningCount", "count", 4.0f, 1.0f, 10.0f); + private final NumberValue 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 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 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 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 points = new ArrayList<>(List.of(start, end)); + points = displacePoints(points, 0.09, 3); + + ArrayList> 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 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 displacePoints(ArrayList input, double amount, int passes) { + ArrayList points = new ArrayList<>(input); + double amt = amount; + for (int pass = 0; pass < passes; pass++) { + ArrayList 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 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 points; + final List> branches; + final long spawn; + final long life; + final float phase; + + Bolt(List points, List> branches, long life, float phase) { + this.points = points; + this.branches = branches; + this.spawn = System.currentTimeMillis(); + this.life = life; + this.phase = phase; + } + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Rain.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Rain.java new file mode 100644 index 0000000..24587cb --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/Rain.java @@ -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 = enumMode("preset", Preset.RAIN, Preset.values()); + private final NumberValue density = num("density", 1.0f, 0.1f, 3.0f); + private final NumberValue radius = num("radius", 26.0f, 8.0f, 64.0f); + private final NumberValue altitude = num("altitude", 16.0f, 6.0f, 40.0f); + private final NumberValue dropSize = num("drop_size", 1.0f, 0.3f, 2.5f); + private final NumberValue fallSpeed = num("fall_speed", 1.0f, 0.3f, 2.5f); + private final NumberValue wind = num("wind", 1.0f, 0.0f, 3.0f); + private final NumberValue 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 = 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 wetness = visibleWhen(num("wetness", 1.25f, 0.1f, 2.0f), wetGround::get); + private final NumberValue puddleCoverage = visibleWhen(num("puddle_coverage", 0.90f, 0.05f, 1.0f), wetGround::get); + private final NumberValue reflectionStrength = visibleWhen(num("reflection_strength", 1.55f, 0.1f, 2.0f), wetGround::get); + private final NumberValue reflectionDistance = visibleWhen(num("reflection_distance", 56.0f, 12.0f, 96.0f), wetGround::get); + private final EnumValue 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 rippleStrength = visibleWhen(num("ripple_strength", 0.8f, 0.0f, 1.6f), () -> wetGround.get() && puddleRipples.get()); + + private final List drops = new ArrayList<>(); + private final List splashList = new ArrayList<>(); + private final List dropletList = new ArrayList<>(); + private final List mistList = new ArrayList<>(); + private final Map 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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ShaderSky.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ShaderSky.java index e009dbd..c2e8af1 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ShaderSky.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/ShaderSky.java @@ -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 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 + ); + } + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/KineticLyrics.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/KineticLyrics.java new file mode 100644 index 0000000..eee4d4e --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/KineticLyrics.java @@ -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 animation = + enumMode("animation", AnimationMode.LYRIC_FLOW, AnimationMode.values()); + private final EnumValue 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 = + enumMode("font", FontMode.ONEST_BOLD, FontMode.values()); + private final EnumValue splitMode = + enumMode("split_mode", SplitMode.SMART_SPLIT, SplitMode.values()); + private final NumberValue maxLines = + num("max_lines", 3, 1, 6); + private final NumberValue minDistance = + num("min_distance", 2.0f, 1.2f, 4.0f); + private final NumberValue maxDistance = + num("max_distance", 4.5f, 2.0f, 7.0f); + private final NumberValue arcSpread = + num("arc_spread", 70.0f, 20.0f, 85.0f); + private final NumberValue floatHeight = + num("float_height", 0.5f, 0.1f, 2.0f); + private final NumberValue timeOffsetMs = + num("time_offset_ms", 0, -5000, 5000); + private final BooleanValue debugMode = + bool("debug", false); + + private final List rawLyrics = new ArrayList<>(); + private final List lyricsQueue = new ArrayList<>(); + private final List 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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClient.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClient.java new file mode 100644 index 0000000..0ca9d46 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClient.java @@ -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> 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> 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 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 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.emptyList(); + }); + } + + private static List 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 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 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 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 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 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(); + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParser.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParser.java new file mode 100644 index 0000000..65c8185 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParser.java @@ -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 parse(String lrcContent) { + if (lrcContent == null || lrcContent.isBlank()) { + return Collections.emptyList(); + } + + List 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; + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLine.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLine.java new file mode 100644 index 0000000..bfcafef --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLine.java @@ -0,0 +1,8 @@ +package dev.loki.lovisual.features.module.modules.visuals.lyrics; + +public record LyricLine(long timestampMs, String text) implements Comparable { + @Override + public int compareTo(LyricLine o) { + return Long.compare(this.timestampMs, o.timestampMs); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitter.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitter.java new file mode 100644 index 0000000..43f3fb9 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitter.java @@ -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 splitLongLines(List originalLines) { + if (originalLines == null || originalLines.isEmpty()) { + return Collections.emptyList(); + } + + List 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 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; + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricParticle3D.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricParticle3D.java new file mode 100644 index 0000000..8aab6ef --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricParticle3D.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java new file mode 100644 index 0000000..47156df --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/trollface/TrollfaceMask.java @@ -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 size = num( + "trollfaceSize", "size", 1.0f, 0.3f, 3.0f + ); + + public final NumberValue 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; + }; + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemInHandRendererMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemInHandRendererMixin.java index e502d33..cc6c0a2 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemInHandRendererMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/ItemInHandRendererMixin.java @@ -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( diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java index e7a7981..dc6dd14 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/LoVisualMixinPlugin.java @@ -51,7 +51,6 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin { "dev.loki.lovisual.mixins.xaero.minimap.AbstractWaypointRenderProviderMixin" ); private static final Set OPTIONAL_XAERO_WORLDMAP_MIXINS = Set.of( - "dev.loki.lovisual.mixins.xaero.worldmap.WorldMapWaypointAccessor", "dev.loki.lovisual.mixins.xaero.worldmap.WaypointRenderProviderMixin" ); private static final Set OPTIONAL_MORECULLING_MIXINS = Set.of( diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MinecraftMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MinecraftMixin.java index 3b10c00..ead663c 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MinecraftMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MinecraftMixin.java @@ -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")) diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MultiPlayerGameModeMixin.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MultiPlayerGameModeMixin.java index 60d9207..573b6cb 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MultiPlayerGameModeMixin.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/MultiPlayerGameModeMixin.java @@ -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. - } + } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/worldmap/WorldMapWaypointAccessor.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/worldmap/WorldMapWaypointAccessor.java deleted file mode 100644 index d614e71..0000000 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/mixins/xaero/worldmap/WorldMapWaypointAccessor.java +++ /dev/null @@ -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); -} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/TextureStorage.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/TextureStorage.java index 030957e..c66ba7f 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/TextureStorage.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/TextureStorage.java @@ -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); } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java index ffde26a..b9fbf3e 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java @@ -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 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). */ diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/SkyShaderRenderer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/SkyShaderRenderer.java new file mode 100644 index 0000000..aebe955 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/postprocess/SkyShaderRenderer.java @@ -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); + } + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/SkyShaderUniforms.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/SkyShaderUniforms.java new file mode 100644 index 0000000..e23f320 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/SkyShaderUniforms.java @@ -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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/WetSurfaceUniforms.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/WetSurfaceUniforms.java new file mode 100644 index 0000000..b4e5099 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/uniform/impl/WetSurfaceUniforms.java @@ -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; + } + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AttackUtil.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AttackUtil.java index 7650cc1..f0c8ba4 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AttackUtil.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/AttackUtil.java @@ -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; } diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/VulcanReachController.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/VulcanReachController.java deleted file mode 100644 index 8c8de16..0000000 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/combat/VulcanReachController.java +++ /dev/null @@ -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> 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 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 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>> iterator = targetHistory.entrySet().iterator(); - while (iterator.hasNext()) { - ArrayDeque 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 entry = getEnchantmentEntry(Enchantments.KNOCKBACK); - return entry != null && EnchantmentHelper.getItemEnchantmentLevel(entry, stack) > 0; - } - - private Holder getEnchantmentEntry(ResourceKey key) { - if (mc.level == null) { - return null; - } - Registry 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); - } - } -} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json index 5ae948e..c64ab3d 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json @@ -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", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json index 8234346..485bc83 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -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": "Цвет фона слота", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_caustic.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_caustic.frag new file mode 100644 index 0000000..f78270c --- /dev/null +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_caustic.frag @@ -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); +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_sky.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_sky.frag new file mode 100644 index 0000000..166fbdb --- /dev/null +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_sky.frag @@ -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); +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_water.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_water.frag new file mode 100644 index 0000000..22199ee --- /dev/null +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/sky_shader_water.frag @@ -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); +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag new file mode 100644 index 0000000..41c4f86 --- /dev/null +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/wet_surface.frag @@ -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); +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_1.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_1.png new file mode 100644 index 0000000..434e26c Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_1.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_2.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_2.png new file mode 100644 index 0000000..3ce9259 Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_2.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_3.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_3.png new file mode 100644 index 0000000..1d82eb5 Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_3.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_4.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_4.png new file mode 100644 index 0000000..6524597 Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_4.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_5.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_5.png new file mode 100644 index 0000000..09353f8 Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_5.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_6.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_6.png new file mode 100644 index 0000000..ee63cdc Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_6.png differ diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_classic.png b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_classic.png new file mode 100644 index 0000000..5e5812b Binary files /dev/null and b/combatant-client-26.2/src/main/resources/assets/lovisual/textures/trollface/trollface_classic.png differ diff --git a/combatant-client-26.2/src/main/resources/lovisual.mixins.json b/combatant-client-26.2/src/main/resources/lovisual.mixins.json index 6b6aaf0..3d22f0f 100644 --- a/combatant-client-26.2/src/main/resources/lovisual.mixins.json +++ b/combatant-client-26.2/src/main/resources/lovisual.mixins.json @@ -177,8 +177,7 @@ "sodium.SodiumVertexConsumerTrackerMixin", "sodium.SodiumWorldRendererFreecamMixin", "xaero.minimap.AbstractWaypointRenderProviderMixin", - "xaero.worldmap.WaypointRenderProviderMixin", - "xaero.worldmap.WorldMapWaypointAccessor" + "xaero.worldmap.WaypointRenderProviderMixin" ], "injectors": { "defaultRequire": 1 diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClientTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClientTest.java new file mode 100644 index 0000000..8d28d8b --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcLibClientTest.java @@ -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 result = future.join(); + assertTrue(result.isEmpty()); + } + + @Test + void fetchLyricsAsyncReturnsEmptyForNullTrack() { + var future = LrcLibClient.fetchLyricsAsync(null, "Artist", false); + List result = future.join(); + assertTrue(result.isEmpty()); + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java new file mode 100644 index 0000000..784d1e4 --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LrcParserTest.java @@ -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 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 lines = LrcParser.parse(lrc); + + assertEquals(1, lines.size()); + assertEquals(65200L, lines.get(0).timestampMs()); + } + + @Test + void parsesWithoutFractionalSeconds() { + String lrc = "[01:30]No fraction"; + List 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 lines = LrcParser.parse(lrc); + + assertEquals(1, lines.size()); + assertEquals(5100L, lines.get(0).timestampMs()); + } + + @Test + void parsesTwoDigitCentisecond() { + String lrc = "[00:05.12]Test"; + List lines = LrcParser.parse(lrc); + + assertEquals(1, lines.size()); + assertEquals(5120L, lines.get(0).timestampMs()); + } + + @Test + void parsesThreeDigitCentisecond() { + String lrc = "[00:05.123]Test"; + List 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 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 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 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 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 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 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()); + } +} \ No newline at end of file diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitterTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitterTest.java new file mode 100644 index 0000000..773190b --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/features/module/modules/visuals/lyrics/LyricLineSplitterTest.java @@ -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 input = Collections.singletonList(line); + List 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 input = Collections.singletonList(line); + List 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 input = Collections.singletonList(line); + List 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 input = Arrays.asList(line1, line2); + List 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 input = Collections.singletonList(line); + List 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 input = Collections.singletonList(line); + List 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 input = Collections.singletonList(line); + List result = LyricLineSplitter.splitLongLines(input); + + assertTrue(result.size() > 1, "5 words should be split"); + } +} \ No newline at end of file