From 0504be5b574956a10c58b02019a6208477831ffb Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 09:47:30 +0200 Subject: [PATCH 01/10] feat(mod): scripted HoldMyItems on LuaJ (hand/item/model scripts, addons, vanilla overrides, particles, camera) Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ --- mod/TODO.md | 70 ++++---- .../visuals/items/held/HoldMyItems.java | 23 +++ .../visuals/items/held/lua/HandScripts.java | 161 ++++++++---------- .../visuals/items/held/lua/ScriptKind.java | 21 +++ .../visuals/items/held/lua/api/PoseApi.java | 5 + .../items/held/lua/ext/HandParticles.java | 149 ++++++++++++++++ .../items/held/lua/ext/ModelAnimator.java | 96 +++++++++++ .../visuals/items/held/lua/ext/QuadOps.java | 53 ++++++ .../items/held/lua/ext/VanillaApi.java | 73 ++++++++ .../items/held/lua/runtime/ScriptFiles.java | 55 ++++++ .../item/ItemInHandAlwaysVisibleMixin.java | 9 + .../render/item/ItemInHandRendererMixin.java | 37 ++-- .../item/model/ItemModelAnimatorMixin.java | 26 +++ .../render/level/CameraScriptMixin.java | 39 +++++ .../holdmyitems/hand_relative_pose.lua | 9 + .../lovisual/holdmyitems/item_model.lua | 7 + .../assets/lovisual/holdmyitems/item_pose.lua | 10 ++ .../resources/assets/lovisual/lang/en_us.json | 1 + .../resources/assets/lovisual/lang/ru_ru.json | 1 + mod/src/main/resources/lovisual.mixins.json | 2 + .../visuals/items/held/lua/QuadOpsTest.java | 42 +++++ 21 files changed, 750 insertions(+), 139 deletions(-) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ScriptKind.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/HandParticles.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/ModelAnimator.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/QuadOps.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/VanillaApi.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptFiles.java create mode 100644 mod/src/main/java/dev/loki/lovisual/mixins/render/item/model/ItemModelAnimatorMixin.java create mode 100644 mod/src/main/java/dev/loki/lovisual/mixins/render/level/CameraScriptMixin.java create mode 100644 mod/src/main/resources/assets/lovisual/holdmyitems/hand_relative_pose.lua create mode 100644 mod/src/main/resources/assets/lovisual/holdmyitems/item_model.lua create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/QuadOpsTest.java diff --git a/mod/TODO.md b/mod/TODO.md index b11d876..eb783ee 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -4,7 +4,7 @@ ## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат) - **KISS / DRY / SOLID** — простая структура, без оверинжиниринга -- **Макс. 200 строк на файл** (было >1500) — превышение = разбить на модули +- **Макс. 250 строк на файл** (было >1500) — превышение = разбить на модули - **Макс. 4 файла на папку** — больше = новая подпапка по смыслу - **Публичный API можно менять свободно** (решение владельца 2026-09-05): KISS важнее обратной совместимости; при рефакторинге разрешено менять @@ -84,9 +84,9 @@ ### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0 ## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА -> Стратегия: публичный фасад исключён из лимита ≤200 (делегирует ~все публичные методы), +> Стратегия: публичный фасад исключён из лимита ≤250 (делегирует ~все публичные методы), > вся логика вынесена в package-private final-хелперы в том же пакете (плоско), -> ≤200 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/ +> ≤250 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/ > AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно. - [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors, Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures) @@ -105,15 +105,15 @@ SearchOverlay, Outline, MeshBuilder, ColorUtil + Model) - [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model) -### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤200, фасады исключены; +### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤250, фасады исключены; ### 0 вызывающих файлов изменено во всей Фазе 3 -### Долг (файлы >200 вне списка гигантов, в т.ч. новые фасады): +### Долг (файлы >250 вне списка гигантов, в т.ч. новые фасады): ### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449, ### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116, ### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042, ### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441, -### а также многие HUD/module/rendering-файлы 200–800 строк. Разбирать по желанию. +### а также многие HUD/module/rendering-файлы 250–800 строк. Разбирать по желанию. ## Фаза 3.5: Удаление читерских модулей (легит-направление) > Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются. @@ -230,29 +230,29 @@ ### CommandDiscoveryTest обновлён) ## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН -> Правило ≤200 уже есть (Фаза 1–3), но долг: ~240 файлов >200 строк. +> Правило ≤250 уже есть (Фаза 1–3), но долг: ~280 файлов >250 строк. > У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) / > inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов. - [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527, DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181, HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042 -- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤200, 0 изменений вызывающих, +- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤250, 0 изменений вызывающих, `./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу -- [ ] Facades >200 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам) +- [ ] Facades >250 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам) - [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша ### Проверка: `./gradlew test` зелёный -## Фаза 8.5: Аудит структуры (правила ≤200 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ +## Фаза 8.5: Аудит структуры (правила ≤250 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ > Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк). > **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать, > должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком. -> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤200, 0 правок вызывающих, +> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤250, 0 правок вызывающих, > билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже. ### Итоговая картина -- **Файлы >200 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140 +- **Файлы >250 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140 - **Папки >4 файлов: 86 шт.** - Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 | features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное @@ -296,7 +296,7 @@ > Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк > (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53 > пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон, -> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤200». +> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤250». > Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка: > Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные > классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`. @@ -304,15 +304,15 @@ далее 1597→475 наследованием (волна 2026-09-25 ниже) (исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners, - −115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >200 нет), + −115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >250 нет), хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓ - [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/` - (Screen 132, Controller 200, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100 - + render/cards/ 178/166), все ≤200, build+test ✓ + (Screen 132, Controller 250, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100 + + render/cards/ 178/166), все ≤250, build+test ✓ - [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) + render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) + render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓ -- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤200 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/` +- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤250 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/` - [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами - [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести - [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно @@ -322,7 +322,7 @@ - [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!) - [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/ (Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183 - + render/cards/ 79/88), все ≤200 — по образцу AltManager + + render/cards/ 79/88), все ≤250 — по образцу AltManager - [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий) - [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы - [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния @@ -332,8 +332,8 @@ Cosmetics built-in (2026-09-11, см. Фазу 7), ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166, GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) + - mixins/render/main/handlers/ 4×≤200 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост) -- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤200 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791, + mixins/render/main/handlers/ 4×≤250 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост) +- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤250 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791, HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739, Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701, Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673, @@ -345,7 +345,7 @@ CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы +bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7, CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435. - bars/ закрыт: все файлы ≤200, impl/ = 3 файла. + bars/ закрыт: все файлы ≤250, impl/ = 3 файла. - [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше, 7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d (SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены; @@ -355,7 +355,7 @@ Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся (0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит; - Base.self() для this→Renderer2D-делегатов; семейства >200 — фасад-части + Base.self() для this→Renderer2D-делегатов; семейства >250 — фасад-части (исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+ 9fffc58d, build+test+checkstyle ✓. @@ -373,9 +373,9 @@ 76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов) 71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге; - файлов >200: 155 → 151 + файлов >250: 155 → 151 - [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом - разбивка на ≤200, вторым — целевое изменение (иначе diff-каша, правило Фазы 8) + разбивка на ≤250, вторым — целевое изменение (иначе diff-каша, правило Фазы 8) ### 8.5.4 Порядок и критерии готовности - [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) → @@ -384,12 +384,12 @@ - [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов не трогают string-literals id — проверять grep'ом после moves. -- [x] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize +- [x] Критерий закрытия для старта 9.2: ноль файлов >250 в путях, которые трогает Optimize (Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect, MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем 244/86 — долгий хвост, 9.2 им не блокируется. → 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓ - (glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >200: + (glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >250: TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами (targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) → исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ. @@ -429,7 +429,7 @@ ### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии > ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) — -> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >200 строк. +> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >250 строк. > Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS. > Пока активен — движок и HUD работают в лёгком режиме. - [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит @@ -705,8 +705,16 @@ hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`. Не сделано из 1-8: `hand_relative_pose`, аддоны (`*_addon.lua`), подмена ванильного equip/swing скриптом, обе руки всегда, ItemStack-поля. Не проверено вживую: направление осей M:rotate на реальной руке. -- [ ] 1b. HMI хвост (пункты 9-16): счётчики свинга и события, обе руки, ItemStack-поля, use_duration/using_item, - аниматор частей модели (`item_model.lua`), камера из Lua, Lua-частицы, клавиша осмотра. +- [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны + `hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`, + выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем + кадре); настройка «Всегда рисовать обе руки» (`always_hands`); `animator` (`ext/ModelAnimator` + миксин + `ItemModelAnimatorMixin` подменяет список квадов слоя, операции rotate/move/scale по диапазону индексов, pivot + в пикселях); `particleManager:addParticle{...}` с Lua-колбэком `tick` (`ext/HandParticles`, аддитивный BLOOM в + мире); `C:setCamRot/setCamPos` (`CameraScriptMixin`, действует 150 мс после вызова). Встроенные примеры для всех + скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/ + `using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков. + Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели. - [ ] 2. Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов, общий `BedrockGeoParser` + бейк + сэмплер). - [ ] 3. Модуль Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person. - [ ] 4. Быстрые модули: TNTTimer, AutoSprint, TotemCounter, PlaceAnimation, EnchantGlint, ArmorColor, GodRays, ProjectileTrails. @@ -725,7 +733,7 @@ (`ClickGuiScreenManager`, три места `new ClickGuiScreen()`), `classic` по умолчанию, второй вид позже; бинд и звуки остаются в модуле `ClickGui`. Ждёт решения, какой второй вид делать первым. - [ ] `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`. -- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >200 строк), по мере касания. +- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >250 строк), по мере касания. - [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде. ### Заметки для следующих сессий @@ -735,5 +743,5 @@ `flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`. - Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*` (Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`. -- Лимиты проекта: ≤200 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении), +- Лимиты проекта: ≤250 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении), без длинного тире в текстах сайта и ответах. diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java index c8d078f..869e011 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/HoldMyItems.java @@ -5,6 +5,7 @@ import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.primitive.NumberValue; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.HandScripts; import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.util.animation.EaseCurves; @@ -55,6 +56,28 @@ public final class HoldMyItems extends Module { public final NumberValue gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get); public final NumberValue gripRoll = visibleWhen(num("grip_roll", 0.0f, -180.0f, 180.0f), gripEnabled::get); + /** Принудительно рисовать обе руки (как AlwaysVisibleHand), если включён режим со скриптами. */ + public final BooleanValue alwaysHands = visibleWhen(bool("always_hands", false), this::scriptsActive); + + public boolean forcesBothHands() { + return isEnabled() && scriptsActive() && alwaysHands.get(); + } + + /** Скрипты, если они уже создавались (для миксинов камеры и свинга), без ленивой загрузки LuaJ. */ + public HandScripts scriptsOrNull() { + return isEnabled() && scriptsActive() ? scripts : null; + } + + @Override + public void onTick() { + if (scripts != null && scriptsActive()) scripts.particles().tick(); + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depth, float partialTicks) { + if (scripts != null && isEnabled() && scriptsActive()) scripts.particles().render(renderer, partialTicks); + } + public boolean dynamicsActive() { return !"Scripts".equals(handMode.get()); } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java index b3abf17..eddcc5d 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/HandScripts.java @@ -1,14 +1,17 @@ package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; import com.mojang.blaze3d.vertex.PoseStack; +import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.Easings; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.EntityApi; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.PoseApi; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.HandParticles; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.ModelAnimator; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.VanillaApi; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptFiles; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptSlot; -import net.fabricmc.loader.api.FabricLoader; -import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.HumanoidArm; @@ -22,30 +25,24 @@ import org.luaj.vm2.LuaValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - /** - * Скрипты руки: {@code hand_pose.lua} (поза всей руки) и {@code item_pose.lua} (поза предмета). - * Источник: {@code config/lovisual/holdmyitems/.lua} (горячая перезагрузка по mtime раз в - * секунду), иначе встроенный пример из ресурсов мода. Ошибка скрипта пишется в лог один раз и - * отключает только этот скрипт до правки файла. + * Скрипты руки (см. {@link ScriptKind}): hand_pose, hand_relative_pose, item_pose, item_model и их аддоны. + * Порядок за кадр руки: hand_pose, hand_relative_pose, (ваниль), item_pose, item_model. Источник и горячая + * перезагрузка по mtime раз в секунду: {@link ScriptFiles}. Ошибка скрипта пишется в лог один раз и отключает + * только этот скрипт до правки файла. */ public final class HandScripts { - public static final String HAND_POSE = "hand_pose"; - public static final String ITEM_POSE = "item_pose"; - private static final Logger LOG = LoggerFactory.getLogger(HandScripts.class); private static final long RECHECK_MS = 1000L; private final LuaSandbox sandbox = new LuaSandbox(); private final PoseApi pose = new PoseApi(); - private final long[] stamps = {Long.MIN_VALUE, Long.MIN_VALUE}; - private final ScriptSlot[] slots = new ScriptSlot[2]; + private final VanillaApi vanilla = new VanillaApi(); + private final HandParticles particles = new HandParticles(sandbox, pose); + private final ScriptKind[] kinds = ScriptKind.values(); + private final ScriptSlot[][] slots = new ScriptSlot[kinds.length][2]; + private final long[][] stamps = new long[kinds.length][2]; private final float[][] lastState = new float[2][2]; private long lastCheck; private Player player; @@ -56,66 +53,82 @@ public final class HandScripts { g.set("M", pose.table()); g.set("P", EntityApi.player(() -> player)); g.set("I", EntityApi.item(() -> stack)); + g.set("V", vanilla.vanillaTable()); + g.set("C", vanilla.cameraTable()); + g.set("animator", ModelAnimator.INSTANCE.table()); + g.set("particleManager", particles.table()); LuaTable easings = new LuaTable(); Easings.all().forEach((name, f) -> LuaTables.put(easings, name, a -> LuaValue.valueOf(f.applyAsDouble(LuaTables.num(a, 2, 0))))); g.set("Easings", easings); + reload(); } - /** Применяет hand_pose к матрице руки; true, если был pushPose и нужен popPose. */ + public VanillaApi vanilla() { + return vanilla; + } + + public HandParticles particles() { + return particles; + } + + /** Принудительная перезагрузка всех скриптов на ближайшем кадре. */ + public final void reload() { + for (long[] row : stamps) java.util.Arrays.fill(row, Long.MIN_VALUE); + lastCheck = 0L; + particles.clear(); + } + + /** Применяет hand_pose и hand_relative_pose к матрице руки; true, если был pushPose и нужен popPose. */ public static boolean applyHandPose(HoldMyItems module, AbstractClientPlayer player, InteractionHand hand, float swing, float equip, float tickDelta, ItemStack item, PoseStack ms) { if (!module.scriptsActive()) return false; boolean mainHand = hand == InteractionHand.MAIN_HAND; boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand; ms.pushPose(); - module.scripts().runHand(new HandFrame(player, item, mainHand, rightArm, swing, equip, tickDelta, ms)); + HandScripts s = module.scripts(); + HandFrame f = new HandFrame(player, item, mainHand, rightArm, swing, equip, tickDelta, ms); + s.vanilla.begin(mainHand); + s.run(ScriptKind.HAND, f); + s.run(ScriptKind.RELATIVE, f); + s.lastState[mainHand ? 0 : 1][0] = swing; + s.lastState[mainHand ? 0 : 1][1] = equip; return true; } - /** Применяет item_pose к матрице предмета; true, если был pushPose и нужен popPose. */ + /** Применяет item_pose и item_model; true, если был pushPose и нужен popPose. */ public static boolean applyItemPose(HoldMyItems module, LivingEntity entity, ItemStack stack, ItemDisplayContext ctx, PoseStack ms) { if (!ctx.firstPerson() || !(entity instanceof AbstractClientPlayer player)) return false; if (!module.isEnabled() || !module.scriptsActive()) return false; boolean rightArm = ctx == ItemDisplayContext.FIRST_PERSON_RIGHT_HAND; boolean mainHand = (player.getMainArm() == HumanoidArm.RIGHT) == rightArm; - HandScripts scripts = module.scripts(); - float[] last = scripts.lastState(mainHand); + HandScripts s = module.scripts(); + float[] last = s.lastState[mainHand ? 0 : 1]; + HandFrame f = new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms); ms.pushPose(); - scripts.runItem(new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms)); + s.run(ScriptKind.ITEM, f); + ModelAnimator.INSTANCE.clear(); + s.sandbox.globals().set("quadCount", LuaValue.valueOf(ModelAnimator.INSTANCE.lastQuadCount())); + s.run(ScriptKind.MODEL, f); return true; } - public static Path scriptDir() { - return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("holdmyitems"); - } - - /** Принудительная перезагрузка на следующем кадре. */ - public void reload() { - stamps[0] = Long.MIN_VALUE; - stamps[1] = Long.MIN_VALUE; - lastCheck = 0L; - } - - public void runHand(HandFrame f) { - run(0, HAND_POSE, f); - lastState[f.mainHand() ? 0 : 1][0] = f.swingProgress(); - lastState[f.mainHand() ? 0 : 1][1] = f.equipProgress(); - } - - public void runItem(HandFrame f) { - run(1, ITEM_POSE, f); - } - - /** Последний известный swing/equip руки: item_pose рендерится вне submitArmWithItem. */ - public float[] lastState(boolean mainHand) { - return lastState[mainHand ? 0 : 1]; - } - - private void run(int index, String name, HandFrame f) { + private void run(ScriptKind kind, HandFrame f) { refresh(); - ScriptSlot slot = slots[index]; - if (slot == null || !slot.canRun()) return; + for (int part = 0; part < 2; part++) { + ScriptSlot slot = slots[kind.ordinal()][part]; + if (slot == null || !slot.canRun()) continue; + bind(slot, f); + try { + slot.run(); + } finally { + pose.finish(); + } + if (!slot.canRun()) LOG.warn("HoldMyItems script {} disabled: {}", kind.file(part), slot.error()); + } + } + + private void bind(ScriptSlot slot, HandFrame f) { this.player = f.player(); this.stack = f.stack(); Globals g = sandbox.globals(); @@ -129,47 +142,23 @@ public final class HandScripts { g.set("useDuration", LuaValue.valueOf(f.player().getTicksUsingItem())); g.set("time", LuaValue.valueOf(System.nanoTime() / 1.0e9)); pose.bind(f.pose()); - try { - slot.run(); - } finally { - pose.finish(); - } - if (!slot.canRun()) LOG.warn("HoldMyItems script {} disabled: {}", name, slot.error()); } private void refresh() { long now = System.currentTimeMillis(); if (now - lastCheck < RECHECK_MS) return; lastCheck = now; - String[] names = {HAND_POSE, ITEM_POSE}; - for (int i = 0; i < names.length; i++) { - Path file = scriptDir().resolve(names[i] + ".lua"); - long stamp = stampOf(file); - if (stamp == stamps[i]) continue; - stamps[i] = stamp; - String source = read(file, names[i]); - slots[i] = source == null ? null : new ScriptSlot(names[i], source, sandbox); - if (slots[i] != null && !slots[i].canRun()) LOG.warn("HoldMyItems script {}: {}", names[i], slots[i].error()); - } - } - - private static long stampOf(Path file) { - try { - return Files.isRegularFile(file) ? Files.getLastModifiedTime(file).toMillis() : -1L; - } catch (IOException e) { - return -1L; - } - } - - private static String read(Path file, String name) { - try { - if (Files.isRegularFile(file)) return Files.readString(file, StandardCharsets.UTF_8); - try (InputStream in = HandScripts.class.getResourceAsStream("/assets/lovisual/holdmyitems/" + name + ".lua")) { - return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); + for (ScriptKind kind : kinds) { + for (int part = 0; part < 2; part++) { + String name = kind.file(part); + long stamp = ScriptFiles.stamp(name, part == 0); + if (stamp == stamps[kind.ordinal()][part]) continue; + stamps[kind.ordinal()][part] = stamp; + String source = stamp < 0 ? null : ScriptFiles.read(name); + ScriptSlot slot = source == null ? null : new ScriptSlot(name, source, sandbox); + slots[kind.ordinal()][part] = slot; + if (slot != null && !slot.canRun()) LOG.warn("HoldMyItems script {}: {}", name, slot.error()); } - } catch (IOException e) { - LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage()); - return null; } } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ScriptKind.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ScriptKind.java new file mode 100644 index 0000000..e0bf9b2 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ScriptKind.java @@ -0,0 +1,21 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; + +/** Виды скриптов HoldMyItems: основной файл и необязательный аддон, который выполняется сразу после него. */ +public enum ScriptKind { + HAND("hand_pose", "hand_addon"), + RELATIVE("hand_relative_pose", "hand_relative_addon"), + ITEM("item_pose", "item_addon"), + MODEL("item_model", "item_model_addon"); + + public final String main; + public final String addon; + + ScriptKind(String main, String addon) { + this.main = main; + this.addon = addon; + } + + public String file(int part) { + return part == 0 ? main : addon; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java index c353222..fba7340 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/api/PoseApi.java @@ -21,6 +21,11 @@ public final class PoseApi { return this; } + /** Текущая матрица позы (копия) либо null, если скрипт не выполняется. */ + public org.joml.Matrix4f matrix() { + return stack == null ? null : new org.joml.Matrix4f(stack.last().pose()); + } + /** Закрывает push, оставленные скриптом незакрытыми. */ public void finish() { while (stack != null && depth > 0) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/HandParticles.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/HandParticles.java new file mode 100644 index 0000000..ee1b9ae --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/HandParticles.java @@ -0,0 +1,149 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.PoseApi; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox; +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import net.minecraft.client.Minecraft; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.luaj.vm2.LuaError; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices; +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity; + +/** + * Lua-глобал {@code particleManager}: {@code particleManager:addParticle{ x,y,z, vx,vy,vz, lifetime, size, color, + * alpha, gravity, drag, tick = function(p) ... end }}. Координаты x,y,z заданы в системе текущей позы руки + * (после M:), частица живёт в мире. Колбэк tick получает таблицу p и может менять x,y,z,vx,vy,vz,size,alpha,color. + */ +public final class HandParticles { + + private static final Logger LOG = LoggerFactory.getLogger(HandParticles.class); + private static final int MAX = 256; + + private static final class P { + float x, y, z, px, py, pz, vx, vy, vz, size, alpha, gravity, drag; + int age, life, color; + LuaValue tick; + } + + private final List

list = new ArrayList<>(); + private final LuaSandbox sandbox; + private final PoseApi pose; + + public HandParticles(LuaSandbox sandbox, PoseApi pose) { + this.sandbox = sandbox; + this.pose = pose; + } + + public void clear() { + list.clear(); + } + + public LuaTable table() { + LuaTable t = new LuaTable(); + LuaTables.put(t, "addParticle", a -> { + if (list.size() < MAX && a.arg(2).istable()) spawn(a.arg(2)); + return LuaValue.NIL; + }); + return t; + } + + private void spawn(LuaValue o) { + Matrix4f m = pose.matrix(); + Minecraft mc = Minecraft.getInstance(); + if (m == null || mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return; + Vector3f local = m.transformPosition(num(o, "x", 0), num(o, "y", 0), num(o, "z", 0), new Vector3f()); + Vector3f world = new Quaternionf(RenderState.cameraRotation).transform(local); + Vec3 cam = mc.gameRenderer.mainCamera().position(); + P p = new P(); + p.x = p.px = (float) cam.x + world.x; + p.y = p.py = (float) cam.y + world.y; + p.z = p.pz = (float) cam.z + world.z; + Vector3f v = new Quaternionf(RenderState.cameraRotation).transform(new Vector3f(num(o, "vx", 0), num(o, "vy", 0), num(o, "vz", 0))); + p.vx = v.x; + p.vy = v.y; + p.vz = v.z; + p.life = Math.max(1, Math.min(200, (int) num(o, "lifetime", 20))); + p.size = num(o, "size", 0.05f); + p.alpha = num(o, "alpha", 1f); + p.gravity = num(o, "gravity", 0f); + p.drag = num(o, "drag", 0.98f); + LuaValue c = o.get("color"); + p.color = c.isnil() ? 0xFFFFFFFF : (int) (long) c.todouble(); + LuaValue tick = o.get("tick"); + p.tick = tick.isfunction() ? tick : null; + list.add(p); + } + + private static float num(LuaValue o, String key, float def) { + LuaValue v = o.get(key); + return v.isnumber() ? (float) v.todouble() : def; + } + + public void tick() { + for (int i = list.size() - 1; i >= 0; i--) { + P p = list.get(i); + p.px = p.x; + p.py = p.y; + p.pz = p.z; + p.vy -= p.gravity; + p.vx *= p.drag; + p.vy *= p.drag; + p.vz *= p.drag; + p.x += p.vx; + p.y += p.vy; + p.z += p.vz; + if (p.tick != null) callTick(p); + if (++p.age >= p.life) list.remove(i); + } + } + + private void callTick(P p) { + LuaTable t = new LuaTable(); + t.set("x", p.x); t.set("y", p.y); t.set("z", p.z); + t.set("vx", p.vx); t.set("vy", p.vy); t.set("vz", p.vz); + t.set("size", p.size); t.set("alpha", p.alpha); t.set("color", (double) (p.color & 0xFFFFFFFFL)); + t.set("age", p.age); t.set("life", p.life); + sandbox.beginRun(); + try { + p.tick.call(t); + p.x = num(t, "x", p.x); p.y = num(t, "y", p.y); p.z = num(t, "z", p.z); + p.vx = num(t, "vx", p.vx); p.vy = num(t, "vy", p.vy); p.vz = num(t, "vz", p.vz); + p.size = num(t, "size", p.size); p.alpha = num(t, "alpha", p.alpha); + p.color = (int) (long) t.get("color").todouble(); + } catch (LuaError e) { + LOG.warn("HoldMyItems particle tick failed: {}", e.getMessage()); + p.tick = null; + } + } + + public void render(Renderer3D renderer, float partialTicks) { + if (renderer == null || list.isEmpty()) return; + MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, + TextureStorage.BLOOM, Renderer3D.DepthMode.NONE); + if (mesh == null) return; + for (P p : list) { + float fade = 1.0f - (p.age + partialTicks) / p.life; + int color = applyOpacity(p.color, p.alpha * Math.max(0f, fade)); + Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation).mul(p.size); + Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation).mul(p.size); + addBillboardVertices(mesh, p.px + (p.x - p.px) * partialTicks, p.py + (p.y - p.py) * partialTicks, + p.pz + (p.z - p.pz) * partialTicks, right, up, color, color, color, color); + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/ModelAnimator.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/ModelAnimator.java new file mode 100644 index 0000000..4dedd57 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/ModelAnimator.java @@ -0,0 +1,96 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Op; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Type; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import org.joml.Vector3f; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.util.ArrayList; +import java.util.List; + +/** + * Lua-глобал {@code animator} для item_model.lua: анимация частей (квадов) модели предмета. + * {@code animator:rotateX(from, to, deg, px, py, pz)}, {@code moveX/Y/Z(from, to, px)}, {@code scale(from, to, k)} + * по диапазону индексов квадов; pivot и сдвиги в пикселях. Операции действуют на один рендер предмета. + */ +public final class ModelAnimator { + + public static final ModelAnimator INSTANCE = new ModelAnimator(); + private static final float PX = 1.0f / 16.0f; + + private final List ops = new ArrayList<>(); + private int lastQuadCount; + + private ModelAnimator() { + } + + public void clear() { + ops.clear(); + } + + /** Число квадов последнего отрендеренного слоя (скрипту отдаётся глобалом quadCount). */ + public int lastQuadCount() { + return lastQuadCount; + } + + public LuaTable table() { + LuaTable t = new LuaTable(); + LuaTables.put(t, "rotateX", a -> rotate(Type.ROT_X, a)); + LuaTables.put(t, "rotateY", a -> rotate(Type.ROT_Y, a)); + LuaTables.put(t, "rotateZ", a -> rotate(Type.ROT_Z, a)); + LuaTables.put(t, "moveX", a -> move(Type.MOVE_X, a)); + LuaTables.put(t, "moveY", a -> move(Type.MOVE_Y, a)); + LuaTables.put(t, "moveZ", a -> move(Type.MOVE_Z, a)); + LuaTables.put(t, "scale", a -> { + ops.add(new Op(Type.SCALE, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0), + (float) LuaTables.num(a, 4, 1), (float) LuaTables.num(a, 5, 8) * PX, + (float) LuaTables.num(a, 6, 8) * PX, (float) LuaTables.num(a, 7, 8) * PX)); + return LuaValue.NIL; + }); + return t; + } + + private LuaValue rotate(Type type, Varargs a) { + ops.add(new Op(type, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0), (float) LuaTables.num(a, 4, 0), + (float) LuaTables.num(a, 5, 8) * PX, (float) LuaTables.num(a, 6, 8) * PX, (float) LuaTables.num(a, 7, 8) * PX)); + return LuaValue.NIL; + } + + private LuaValue move(Type type, Varargs a) { + ops.add(new Op(type, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0), + (float) LuaTables.num(a, 4, 0) * PX, 0, 0, 0)); + return LuaValue.NIL; + } + + /** Вызывается миксином перед отправкой квадов на рендер: возвращает изменённую копию либо исходный список. */ + public List apply(List quads) { + lastQuadCount = quads.size(); + if (ops.isEmpty()) return quads; + List out = new ArrayList<>(quads.size()); + for (int i = 0; i < quads.size(); i++) { + BakedQuad q = quads.get(i); + out.add(covered(i) ? transform(q, i) : q); + } + return out; + } + + private boolean covered(int index) { + for (Op op : ops) if (op.covers(index)) return true; + return false; + } + + private BakedQuad transform(BakedQuad q, int index) { + Vector3f[] p = new Vector3f[4]; + for (int v = 0; v < 4; v++) { + float[] pos = {q.position(v).x(), q.position(v).y(), q.position(v).z()}; + for (Op op : ops) if (op.covers(index)) QuadOps.apply(op, pos); + p[v] = new Vector3f(pos[0], pos[1], pos[2]); + } + return new BakedQuad(p[0], p[1], p[2], p[3], q.packedUV0(), q.packedUV1(), q.packedUV2(), q.packedUV3(), + q.direction(), q.materialInfo()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/QuadOps.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/QuadOps.java new file mode 100644 index 0000000..4e49306 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/QuadOps.java @@ -0,0 +1,53 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext; + +/** + * Чистая математика аниматора частей модели: операции над вершиной (x,y,z в блоках, 0..1). + * Углы в градусах, сдвиги и pivot приходят из Lua в пикселях (1/16 блока) и уже переведены вызывающим. + */ +public final class QuadOps { + + public enum Type { ROT_X, ROT_Y, ROT_Z, MOVE_X, MOVE_Y, MOVE_Z, SCALE } + + /** Операция над квадами с индексами [from, to]; a: градусы/блоки/множитель, p: pivot в блоках. */ + public record Op(Type type, int from, int to, float a, float px, float py, float pz) { + public boolean covers(int quadIndex) { + return quadIndex >= from && quadIndex <= to; + } + } + + private QuadOps() { + } + + /** Применяет операцию к вершине in-place. */ + public static void apply(Op op, float[] v) { + switch (op.type()) { + case MOVE_X -> v[0] += op.a(); + case MOVE_Y -> v[1] += op.a(); + case MOVE_Z -> v[2] += op.a(); + case SCALE -> { + v[0] = op.px() + (v[0] - op.px()) * op.a(); + v[1] = op.py() + (v[1] - op.py()) * op.a(); + v[2] = op.pz() + (v[2] - op.pz()) * op.a(); + } + default -> rotate(op, v); + } + } + + private static void rotate(Op op, float[] v) { + double rad = Math.toRadians(op.a()); + float c = (float) Math.cos(rad); + float s = (float) Math.sin(rad); + float x = v[0] - op.px(); + float y = v[1] - op.py(); + float z = v[2] - op.pz(); + float nx = x, ny = y, nz = z; + switch (op.type()) { + case ROT_X -> { ny = y * c - z * s; nz = y * s + z * c; } + case ROT_Y -> { nx = x * c + z * s; nz = -x * s + z * c; } + default -> { nx = x * c - y * s; ny = x * s + y * c; } + } + v[0] = nx + op.px(); + v[1] = ny + op.py(); + v[2] = nz + op.pz(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/VanillaApi.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/VanillaApi.java new file mode 100644 index 0000000..e909526 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/ext/VanillaApi.java @@ -0,0 +1,73 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables; +import org.luaj.vm2.LuaTable; +import org.luaj.vm2.LuaValue; + +/** + * Lua-глобалы {@code V} и {@code C}. + * V: {@code V:cancelEquip(true)} отключает ванильный подъём предмета, {@code V:cancelSwing(true)} ванильный свинг + * (скрипт сам рисует их через hand_pose); флаги читаются миксином на следующем кадре. + * C: {@code C:setCamRot(yaw, pitch)} добавка к повороту камеры в градусах, {@code C:setCamPos(surge, heave, sway)} + * сдвиг камеры в блоках; действует, пока скрипт обновляет значения (до 150 мс без вызова). + */ +public final class VanillaApi { + + private static final long CAM_TTL_MS = 150L; + + private final boolean[] cancelEquip = new boolean[2]; + private final boolean[] cancelSwing = new boolean[2]; + private final float[] cam = new float[5]; + private long camStamp; + private int current; + + /** Начало кадра руки: флаги этой руки сбрасываются и выставляются скриптом заново. */ + public void begin(boolean mainHand) { + current = mainHand ? 0 : 1; + cancelEquip[current] = false; + cancelSwing[current] = false; + } + + public boolean cancelEquip(boolean mainHand) { + return cancelEquip[mainHand ? 0 : 1]; + } + + public boolean cancelSwing(boolean mainHand) { + return cancelSwing[mainHand ? 0 : 1]; + } + + /** Добавка камеры {yaw, pitch, surge, heave, sway}, либо null, если скрипт её не задаёт. */ + public float[] cameraOffset() { + return System.currentTimeMillis() - camStamp > CAM_TTL_MS ? null : cam; + } + + public LuaTable vanillaTable() { + LuaTable t = new LuaTable(); + LuaTables.put(t, "cancelEquip", a -> flag(cancelEquip, a.arg(2).toboolean())); + LuaTables.put(t, "cancelSwing", a -> flag(cancelSwing, a.arg(2).toboolean())); + return t; + } + + public LuaTable cameraTable() { + LuaTable t = new LuaTable(); + LuaTables.put(t, "setCamRot", a -> cam(0, a.arg(2).optdouble(0), a.arg(3).optdouble(0))); + LuaTables.put(t, "setCamPos", a -> { + cam(2, a.arg(2).optdouble(0), a.arg(3).optdouble(0)); + cam[4] = (float) a.arg(4).optdouble(0); + return LuaValue.NIL; + }); + return t; + } + + private LuaValue flag(boolean[] array, boolean value) { + array[current] = value; + return LuaValue.NIL; + } + + private LuaValue cam(int at, double first, double second) { + cam[at] = (float) first; + cam[at + 1] = (float) second; + camStamp = System.currentTimeMillis(); + return LuaValue.NIL; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptFiles.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptFiles.java new file mode 100644 index 0000000..9e7b89b --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/runtime/ScriptFiles.java @@ -0,0 +1,55 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime; + +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Поиск исходников скриптов: {@code config/lovisual/holdmyitems/.lua}, иначе встроенный пример + * из ресурсов мода (только для основных скриптов; аддоны бывают лишь в конфиге). + */ +public final class ScriptFiles { + + private static final Logger LOG = LoggerFactory.getLogger(ScriptFiles.class); + + private ScriptFiles() { + } + + public static Path dir() { + return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("holdmyitems"); + } + + /** Метка изменения: mtime файла, 0 для встроенного примера, -1 если источника нет. */ + public static long stamp(String name, boolean builtinAllowed) { + Path file = dir().resolve(name + ".lua"); + try { + if (Files.isRegularFile(file)) return Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + return -1L; + } + return builtinAllowed && ScriptFiles.class.getResource(resource(name)) != null ? 0L : -1L; + } + + public static String read(String name) { + Path file = dir().resolve(name + ".lua"); + try { + if (Files.isRegularFile(file)) return Files.readString(file, StandardCharsets.UTF_8); + try (InputStream in = ScriptFiles.class.getResourceAsStream(resource(name))) { + return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException e) { + LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage()); + return null; + } + } + + private static String resource(String name) { + return "/assets/lovisual/holdmyitems/" + name + ".lua"; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandAlwaysVisibleMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandAlwaysVisibleMixin.java index 6ebfe58..9063d4b 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandAlwaysVisibleMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandAlwaysVisibleMixin.java @@ -3,6 +3,7 @@ package dev.loki.lovisual.mixins.render.item; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.visuals.items.held.AlwaysVisibleHand; +import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; import net.minecraft.client.renderer.ItemInHandRenderer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -25,6 +26,7 @@ public abstract class ItemInHandAlwaysVisibleMixin { ) ) private boolean lovisual$alwaysRenderMainHand(boolean vanillaDecision) { + if (lovisual$forceBoth()) return true; AlwaysVisibleHand module = Modules.get(AlwaysVisibleHand.class); return module == null ? vanillaDecision : module.renderMainHand(vanillaDecision); } @@ -37,7 +39,14 @@ public abstract class ItemInHandAlwaysVisibleMixin { ) ) private boolean lovisual$alwaysRenderOffHand(boolean vanillaDecision) { + if (lovisual$forceBoth()) return true; AlwaysVisibleHand module = Modules.get(AlwaysVisibleHand.class); return module == null ? vanillaDecision : module.renderOffHand(vanillaDecision); } + + @org.spongepowered.asm.mixin.Unique + private static boolean lovisual$forceBoth() { + HoldMyItems held = Modules.get(HoldMyItems.class); + return held != null && held.forcesBothHands(); + } } diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java index 1a202fb..46be5a9 100644 --- a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/ItemInHandRendererMixin.java @@ -21,6 +21,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import dev.loki.lovisual.features.module.lifecycle.Modules; import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.HandScripts; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.ModelAnimator; import dev.loki.lovisual.util.animation.hand.HandDynamics; import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel; import dev.loki.lovisual.util.animation.EquipProgressComposer; @@ -114,6 +115,8 @@ public abstract class ItemInHandRendererMixin { boolean lowerEnabled = viewModel != null && viewModel.isEnabled(); boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled() && holdMyItems.dynamicsActive() && !(hand == InteractionHand.OFF_HAND && !holdMyItems.offhandToo.get()); + HandScripts scripts = holdMyItems == null ? null : holdMyItems.scriptsOrNull(); + if (scripts != null && scripts.vanilla().cancelEquip(hand == InteractionHand.MAIN_HAND)) return 0.0f; if (!lowerEnabled && !curveEnabled) return equipProgress; return EquipProgressComposer.compose( equipProgress, @@ -137,15 +140,8 @@ public abstract class ItemInHandRendererMixin { target = "Lnet/minecraft/client/renderer/item/ItemStackRenderState;submit(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;III)V" ) ) - private void lovisual$scaleMiniItems( - LivingEntity entity, - ItemStack stack, - ItemDisplayContext ctx, - PoseStack ms, - SubmitNodeCollector queue, - int light, - CallbackInfo ci - ) { + private void lovisual$scaleMiniItems(LivingEntity entity, ItemStack stack, ItemDisplayContext ctx, + PoseStack ms, SubmitNodeCollector queue, int light, CallbackInfo ci) { HoldMyItems held = Modules.get(HoldMyItems.class); if (held != null) lovisual$itemPosePushed = HandScripts.applyItemPose(held, entity, stack, ctx, ms); ViewModel viewModel = Modules.get(ViewModel.class); @@ -162,26 +158,23 @@ public abstract class ItemInHandRendererMixin { private void lovisual$restoreItemPose( LivingEntity entity, ItemStack stack, ItemDisplayContext ctx, PoseStack ms, SubmitNodeCollector queue, int light, CallbackInfo ci) { + ModelAnimator.INSTANCE.clear(); if (!lovisual$itemPosePushed) return; lovisual$itemPosePushed = false; ms.popPose(); } - @Inject( - method = "swingArm", - at = @At("HEAD"), - cancellable = true - ) - private void lovisual$customSwing( - float swingProgress, - PoseStack ms, - int direction, - HumanoidArm arm, - CallbackInfo ci - ) { + @Inject(method = "swingArm", at = @At("HEAD"), cancellable = true) + private void lovisual$customSwing(float swingProgress, PoseStack ms, int direction, HumanoidArm arm, CallbackInfo ci) { + Minecraft mc = Minecraft.getInstance(); + HoldMyItems held = Modules.get(HoldMyItems.class); + HandScripts scripts = held == null ? null : held.scriptsOrNull(); + if (scripts != null && scripts.vanilla().cancelSwing(arm == mc.options.mainHand().get())) { + ci.cancel(); + return; + } ViewModel viewModel = Modules.get(ViewModel.class); if (viewModel == null || !viewModel.isEnabled()) return; - Minecraft mc = Minecraft.getInstance(); if (mc.player == null) return; ItemStack stack = mc.player.getMainHandItem(); diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/item/model/ItemModelAnimatorMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/model/ItemModelAnimatorMixin.java new file mode 100644 index 0000000..a1d3ad4 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/item/model/ItemModelAnimatorMixin.java @@ -0,0 +1,26 @@ +package dev.loki.lovisual.mixins.render.item.model; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.ModelAnimator; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import java.util.List; + +/** Подмена списка квадов слоя предмета на анимированную копию (item_model.lua, {@code animator}). */ +@Mixin(targets = "net.minecraft.client.renderer.item.ItemStackRenderState$LayerRenderState") +public abstract class ItemModelAnimatorMixin { + + @ModifyArg( + method = "submit", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/renderer/SubmitNodeCollector;submitItem(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/world/item/ItemDisplayContext;III[ILjava/util/List;Lnet/minecraft/client/renderer/item/ItemStackRenderState$FoilType;)V" + ), + index = 6 + ) + private List lovisual$animateQuads(List quads) { + return ModelAnimator.INSTANCE.apply(quads); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/mixins/render/level/CameraScriptMixin.java b/mod/src/main/java/dev/loki/lovisual/mixins/render/level/CameraScriptMixin.java new file mode 100644 index 0000000..8e46fdb --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/mixins/render/level/CameraScriptMixin.java @@ -0,0 +1,39 @@ +package dev.loki.lovisual.mixins.render.level; + +import dev.loki.lovisual.features.module.lifecycle.Modules; +import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.HandScripts; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Смещение камеры из Lua-скриптов HoldMyItems ({@code C:setCamRot}, {@code C:setCamPos}). */ +@Mixin(Camera.class) +public abstract class CameraScriptMixin { + + @Shadow + protected abstract void setRotation(float yaw, float pitch); + + @Shadow + protected abstract void move(float surge, float heave, float sway); + + @Shadow + public abstract float yRot(); + + @Shadow + public abstract float xRot(); + + @Inject(method = "update", at = @At("TAIL")) + private void lovisual$applyScriptCamera(DeltaTracker tickCounter, CallbackInfo ci) { + HoldMyItems held = Modules.get(HoldMyItems.class); + HandScripts scripts = held == null ? null : held.scriptsOrNull(); + float[] c = scripts == null ? null : scripts.vanilla().cameraOffset(); + if (c == null) return; + if (c[0] != 0.0f || c[1] != 0.0f) setRotation(yRot() + c[0], Math.max(-90.0f, Math.min(90.0f, xRot() + c[1]))); + if (c[2] != 0.0f || c[3] != 0.0f || c[4] != 0.0f) move(c[2], c[3], c[4]); + } +} diff --git a/mod/src/main/resources/assets/lovisual/holdmyitems/hand_relative_pose.lua b/mod/src/main/resources/assets/lovisual/holdmyitems/hand_relative_pose.lua new file mode 100644 index 0000000..a96242c --- /dev/null +++ b/mod/src/main/resources/assets/lovisual/holdmyitems/hand_relative_pose.lua @@ -0,0 +1,9 @@ +-- LoVisual HoldMyItems: поза руки относительно сцены (после hand_pose). +-- Пример: при смене предмета рука чуть "отъезжает" в сторону. Свои флаги ванили: +-- V:cancelEquip(true) отключить ванильный подъём предмета (его заменяет скрипт) +-- V:cancelSwing(true) отключить ванильный свинг +local side = rightArm and 1 or -1 +if equipProgress > 0 then + M:moveX(0.12 * equipProgress * side) + M:rotateZ(-10 * equipProgress * side) +end diff --git a/mod/src/main/resources/assets/lovisual/holdmyitems/item_model.lua b/mod/src/main/resources/assets/lovisual/holdmyitems/item_model.lua new file mode 100644 index 0000000..709a387 --- /dev/null +++ b/mod/src/main/resources/assets/lovisual/holdmyitems/item_model.lua @@ -0,0 +1,7 @@ +-- LoVisual HoldMyItems: анимация частей (квадов) модели предмета. +-- animator:rotateX/Y/Z(from, to, deg, px, py, pz) вращение квадов с индексами from..to вокруг pivot (в пикселях) +-- animator:moveX/Y/Z(from, to, pixels), animator:scale(from, to, k, px, py, pz); quadCount = число квадов. +-- Пример: мечи слегка "дышат" клинком. +if I:isSword() and quadCount > 0 then + animator:rotateZ(0, quadCount, M:sin(time * 2.0) * 1.5, 8, 0, 8) +end diff --git a/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua b/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua index 0034b69..164af9b 100644 --- a/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua +++ b/mod/src/main/resources/assets/lovisual/holdmyitems/item_pose.lua @@ -16,3 +16,13 @@ end if usingItem and (I:getUseAction() == 'eat' or I:getUseAction() == 'drink') then M:rotateZ(M:sin(useDuration * 1.1) * 3) end + +-- искры у зачарованных предметов: Lua-частицы (координаты в системе руки, колбэк tick на каждый тик) +if I:isEnchanted() and M:sin(time * 9) > 0.96 then + particleManager:addParticle({ + x = 0.0, y = 0.2, z = -0.3, + vx = 0.0, vy = 0.01, vz = 0.0, + lifetime = 14, size = 0.02, color = 0xFFB66CFF, gravity = -0.001, + tick = function(p) p.size = p.size * 0.96 end + }) +end diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index d4d1c8c..1e8d1be 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -3341,6 +3341,7 @@ "setting.autorespawn.delay": "Delay", "module.deathcoords.description": "Sends the coordinates of your latest death to the chat.", "setting.deathcoords.show_dimension": "Show dimension", + "setting.holdmyitems.always_hands": "Always render both hands", "setting.holdmyitems.mode": "Mode", "setting.holdmyitems.mode.option.Dynamics": "Dynamics (Java)", "setting.holdmyitems.mode.option.Scripts": "Scripts (Lua)", diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 2fb12e7..ab6e1e7 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -3136,6 +3136,7 @@ "setting.autorespawn.delay": "Задержка", "module.deathcoords.description": "Отправляет в чат координаты вашей последней смерти.", "setting.deathcoords.show_dimension": "Показывать измерение", + "setting.holdmyitems.always_hands": "Всегда рисовать обе руки", "setting.holdmyitems.mode": "Режим", "setting.holdmyitems.mode.option.Dynamics": "Динамика (Java)", "setting.holdmyitems.mode.option.Scripts": "Скрипты (Lua)", diff --git a/mod/src/main/resources/lovisual.mixins.json b/mod/src/main/resources/lovisual.mixins.json index bdfcaa8..0893327 100644 --- a/mod/src/main/resources/lovisual.mixins.json +++ b/mod/src/main/resources/lovisual.mixins.json @@ -156,8 +156,10 @@ "render.item.EquipmentLayerRendererMixin", "render.item.ItemFeatureRendererMixin", "render.item.ItemInHandAlwaysVisibleMixin", + "render.item.model.ItemModelAnimatorMixin", "render.item.ItemInHandRendererMixin", "render.level.CameraMixin", + "render.level.CameraScriptMixin", "render.level.particles.FireworkParticlesStarterMixin", "render.level.LevelRendererMixin", "render.level.particles.ParticleManagerMixin", diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/QuadOpsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/QuadOpsTest.java new file mode 100644 index 0000000..7c21f13 --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/items/held/lua/QuadOpsTest.java @@ -0,0 +1,42 @@ +package dev.loki.lovisual.features.module.modules.visuals.items.held.lua; + +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Op; +import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Type; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class QuadOpsTest { + + @Test + void rotateZ90AroundPivotMovesVertex() { + float[] v = {1f, 0.5f, 0.3f}; + QuadOps.apply(new Op(Type.ROT_Z, 0, 0, 90f, 0.5f, 0.5f, 0f), v); + assertEquals(0.5f, v[0], 1e-5f); + assertEquals(1.0f, v[1], 1e-5f); + assertEquals(0.3f, v[2], 1e-5f); + } + + @Test + void scaleAroundPivotKeepsPivotFixed() { + float[] v = {0.5f, 0.5f, 0.5f}; + QuadOps.apply(new Op(Type.SCALE, 0, 3, 2f, 0.5f, 0.5f, 0.5f), v); + assertArrayEquals(new float[]{0.5f, 0.5f, 0.5f}, v, 1e-6f); + float[] w = {1f, 0.5f, 0.5f}; + QuadOps.apply(new Op(Type.SCALE, 0, 3, 2f, 0.5f, 0.5f, 0.5f), w); + assertEquals(1.5f, w[0], 1e-6f); + } + + @Test + void moveAndRangeCoverage() { + Op op = new Op(Type.MOVE_Y, 2, 4, 0.25f, 0, 0, 0); + assertFalse(op.covers(1)); + assertTrue(op.covers(2)); + assertTrue(op.covers(4)); + assertFalse(op.covers(5)); + float[] v = {0f, 0f, 0f}; + QuadOps.apply(op, v); + assertEquals(0.25f, v[1], 1e-6f); + } +} From e604a48c31b17c8033678576bdc10000c771aa82 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 09:49:02 +0200 Subject: [PATCH 02/10] fix(mod): smaller module description plaque, tooltip auto-scale to GUI scale Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ --- .../ModulesMenuDescriptionRenderer.java | 18 +++++++-------- .../impl/tooltips/BetterTooltips.java | 8 +++++++ .../tooltips/render/TooltipPanelRenderer.java | 22 +++++++++++-------- .../resources/assets/lovisual/lang/en_us.json | 1 + .../resources/assets/lovisual/lang/ru_ru.json | 1 + 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java index 7dc070d..4c1dca9 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/render/ModulesMenuDescriptionRenderer.java @@ -14,7 +14,7 @@ import java.util.List; * cursor and fades out when nothing is hovered. */ final class ModulesMenuDescriptionRenderer { - private static final int MAX_LINES = 3; + private static final int MAX_LINES = 2; private ModulesMenuDescriptionRenderer() { } @@ -35,25 +35,25 @@ final class ModulesMenuDescriptionRenderer { ModulesMenuChromeRenderer.ensureFonts(); TextRenderer titleFont = ModulesMenuChromeRenderer.medium; TextRenderer bodyFont = ModulesMenuChromeRenderer.regular; - float titleSize = 9.0f * scale; - float bodySize = 7.5f * scale; - float pad = 9.0f * scale; - float w = Math.min(screen.areaW * 0.6f, 300.0f * scale); + float titleSize = 7.4f * scale; + float bodySize = 6.2f * scale; + float pad = 6.0f * scale; + float w = Math.min(screen.areaW * 0.36f, 190.0f * scale); List lines = DescriptionLines.wrap(screen.descText, w - pad * 2.0f, s -> ClickGuiRenderer.textWidth(bodyFont, s, bodySize), MAX_LINES); float titleH = ModulesMenuLayout.textHeight(titleFont, titleSize); float lineH = ModulesMenuLayout.textHeight(bodyFont, bodySize) + 1.5f * scale; - float h = pad * 2.0f + titleH + 3.0f * scale + lines.size() * lineH; + float h = pad * 2.0f + titleH + 2.0f * scale + lines.size() * lineH; float x = screen.areaX + (screen.areaW - w) * 0.5f; - float y = Math.max(screen.areaY + 6.0f * scale, panelsTop(screen) - h - 10.0f * scale) + (1.0f - alpha) * 6.0f * scale; + float y = Math.max(screen.areaY + 6.0f * scale, panelsTop(screen) - h - 8.0f * scale) + (1.0f - alpha) * 6.0f * scale; - float radius = 8.0f * scale; + float radius = 6.0f * scale; ModulesMenuGlassRenderer.drawLiquidGlass(x, y, w, h, radius, false, alpha >= 0.999f ? 1.0f : alpha, scale); int veil = ModulesMenuLayout.withAlpha(ModulesMenuStyle.panelBgGlassDark(), alpha * 0.35f); LayoutRender2D.roundedQuad(x, y, w, h, radius, veil, veil, veil, veil); ClickGuiRenderer.drawText(titleFont, screen.descTitle, x + pad, y + pad, titleSize, ModulesMenuLayout.withAlpha(ModulesMenuStyle.text(), alpha), false); - float ty = y + pad + titleH + 3.0f * scale; + float ty = y + pad + titleH + 2.0f * scale; int bodyColor = ModulesMenuLayout.withAlpha(ModulesMenuStyle.textMuted(), alpha); for (String line : lines) { ClickGuiRenderer.drawText(bodyFont, line, x + pad, ty, bodySize, bodyColor, false); diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java index c3926a2..16b2a82 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/BetterTooltips.java @@ -60,6 +60,7 @@ public final class BetterTooltips extends AbstractHudElement { 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 BooleanValue tooltipAutoScale = bool("tooltip_auto_scale", true); private final ItemIdSetValue topIgnore = registerTopIgnore(); private final NumberValue shulkerPreviewSlotSize = visibleWhen(num("shulker_slot_size", 18.0f, 12.0f, 24.0f), this::isShulkerPreviewEnabled); @@ -101,6 +102,13 @@ public final class BetterTooltips extends AbstractHudElement { } String tooltipFontValue() { return tooltipFont.get(); } float tooltipTextSizeValue() { return tooltipTextSize.get(); } + + /** Множитель под масштаб интерфейса: эталон GUI Scale 3, при 2 тултип крупнее, при 4 мельче. */ + float tooltipAutoScale() { + if (!tooltipAutoScale.get()) return 1.0f; + double gui = Math.max(1.0, net.minecraft.client.Minecraft.getInstance().getWindow().getGuiScale()); + return (float) Math.max(0.7, Math.min(2.2, 3.0 / gui)); + } UiScriptModuleHandle tooltipPanelHandle() { return tooltipPanelHandle; } CachedUiScriptRuntime tooltipRuntime() { return tooltipRuntime; } diff --git a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/render/TooltipPanelRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/render/TooltipPanelRenderer.java index 59a3a75..52caa5f 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/render/TooltipPanelRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/gui/hud/nondraggable/impl/tooltips/render/TooltipPanelRenderer.java @@ -127,19 +127,23 @@ final class TooltipPanelRenderer { if (tr == null) return; PreviewLayout preview = ShulkerPreviewCache.buildLayout(); List finalLines = TooltipState.prepareLines(lines, preview != null); - float scale = BetterTooltips.TOOLTIP_TEXT_SCALE * TooltipState.getScaleOverride() * instance.tooltipTextSizeValue(); + float auto = instance.tooltipAutoScale(); + float scale = BetterTooltips.TOOLTIP_TEXT_SCALE * TooltipState.getScaleOverride() * instance.tooltipTextSizeValue() * auto; + int padX = Math.round(BetterTooltips.TOOLTIP_PAD_X * auto); + int padY = Math.round(BetterTooltips.TOOLTIP_PAD_Y * auto); + int titleGap = Math.round(BetterTooltips.TOOLTIP_TITLE_GAP * auto); tr.begin(scale, false, false); int maxW = 0; for (TooltipLine l : finalLines) maxW = Math.max(maxW, (int) Math.ceil(tr.getWidth(l.text(), false))); int lh = (int) Math.ceil(tr.getHeight(false)); tr.end(); if (finalLines.isEmpty()) finalLines = List.of(new TooltipLine(" ", Theme.theme().textPrimary())); - int textH = finalLines.size() * lh + (finalLines.size() > 1 ? BetterTooltips.TOOLTIP_TITLE_GAP : 0) + Math.max(0, finalLines.size() - 2) * BetterTooltips.TOOLTIP_LINE_GAP; + int textH = finalLines.size() * lh + (finalLines.size() > 1 ? titleGap : 0) + Math.max(0, finalLines.size() - 2) * BetterTooltips.TOOLTIP_LINE_GAP; int pw = preview != null ? preview.width() : 0; int ph = preview != null ? preview.height() : 0; int contentW = Math.max(maxW, pw); - int totalW = contentW + BetterTooltips.TOOLTIP_PAD_X * 2; - int totalH = BetterTooltips.TOOLTIP_PAD_Y * 2 + textH + (preview != null ? BetterTooltips.TOOLTIP_PREVIEW_GAP + ph : 0); + int totalW = contentW + padX * 2; + int totalH = padY * 2 + textH + (preview != null ? BetterTooltips.TOOLTIP_PREVIEW_GAP + ph : 0); int x = mouseX, y = mouseY; if (pos != null) { Vector2ic p = pos.positionTooltip(mc.getWindow().getGuiScaledWidth(), mc.getWindow().getGuiScaledHeight(), mouseX, mouseY, totalW, totalH); @@ -157,20 +161,20 @@ final class TooltipPanelRenderer { ViewportContext.beginScaled(ctx); boolean rendered = renderScripted(mc, Renderer2D.COLOR, tr, x, y, totalW, totalH, bg, tl, trc, br, bl, glow, BetterTooltips.TOOLTIP_STROKE_WIDTH, BetterTooltips.TOOLTIP_GLOW_SIZE, 1.0f, instance.tooltipPanelHandle(), instance.tooltipRuntime(), ctx); if (!rendered) drawFallback(x, y, totalW, totalH, bg, tl, trc, br, bl, glow); - int tx = x + BetterTooltips.TOOLTIP_PAD_X, ty = y + BetterTooltips.TOOLTIP_PAD_Y; + int tx = x + padX, ty = y + padY; tr.begin(scale, false, false); for (int i = 0; i < finalLines.size(); i++) { TooltipLine l = finalLines.get(i); tr.render(l.text(), tx, ty, new RenderColor(l.color()), true); ty += lh; - if (i == 0 && finalLines.size() > 1) ty += BetterTooltips.TOOLTIP_TITLE_GAP; else if (i + 1 < finalLines.size()) ty += BetterTooltips.TOOLTIP_LINE_GAP; + if (i == 0 && finalLines.size() > 1) ty += titleGap; else if (i + 1 < finalLines.size()) ty += BetterTooltips.TOOLTIP_LINE_GAP; } tr.end(); ViewportContext.end(ctx); if (preview != null) { - int cw = totalW - BetterTooltips.TOOLTIP_PAD_X * 2; - int px = x + BetterTooltips.TOOLTIP_PAD_X + Math.max(0, (cw - preview.width()) / 2); - int py = y + BetterTooltips.TOOLTIP_PAD_Y + textH + BetterTooltips.TOOLTIP_PREVIEW_GAP; + int cw = totalW - padX * 2; + int px = x + padX + Math.max(0, (cw - preview.width()) / 2); + int py = y + padY + textH + BetterTooltips.TOOLTIP_PREVIEW_GAP; ShulkerPreviewRenderer.render(preview, px, py, ctx); } } diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index 1e8d1be..a234cbc 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -2925,6 +2925,7 @@ "setting.common.shulker_bg_softness": "Background softness", "setting.common.shulker_padding": "Padding", "setting.common.tooltip_font": "Tooltip font", + "setting.common.tooltip_auto_scale": "Auto-scale to GUI scale", "setting.common.tooltip_text_size": "Tooltip text size", "setting.common.shulker_preview": "Shulker Preview", "setting.common.shulker_preview_hold": "Preview hold", diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index ab6e1e7..6601fcf 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -2729,6 +2729,7 @@ "setting.common.shulker_bg_softness": "Мягкость фона", "setting.common.shulker_padding": "Отступ", "setting.common.tooltip_font": "Шрифт тултипа", + "setting.common.tooltip_auto_scale": "Автоподстройка под размер интерфейса", "setting.common.tooltip_text_size": "Размер текста тултипа", "setting.common.shulker_preview": "Превью шалкера", "setting.common.shulker_preview_hold": "Удержание превью", From 483d6bfb93916bc2fd843b73a776e6cc547eadf9 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 09:50:15 +0200 Subject: [PATCH 03/10] docs(mod): detail evo port plan in TODO Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ --- mod/TODO.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/mod/TODO.md b/mod/TODO.md index eb783ee..55f4f12 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -703,8 +703,7 @@ `ScriptSlot` (ошибка выключает только этот скрипт, лог один раз); `api/`: `M` (move/rotate с pivot/scale/push/pop/ math), `P`, `I`, `Easings` (8 функций), без отдачи Java-объектов в Lua. Хуки в `ItemInHandRendererMixin`: hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`. - Не сделано из 1-8: `hand_relative_pose`, аддоны (`*_addon.lua`), подмена ванильного equip/swing скриптом, - обе руки всегда, ItemStack-поля. Не проверено вживую: направление осей M:rotate на реальной руке. + (Хвост из 1-8 закрыт в пункте 1b ниже.) - [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны `hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`, выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем @@ -715,10 +714,34 @@ скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/ `using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков. Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели. -- [ ] 2. Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов, общий `BedrockGeoParser` + бейк + сэмплер). -- [ ] 3. Модуль Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person. -- [ ] 4. Быстрые модули: TNTTimer, AutoSprint, TotemCounter, PlaceAnimation, EnchantGlint, ArmorColor, GodRays, ProjectileTrails. -- [ ] 5. DeathEffects, Blizzard, ElytraTrails. +- [ ] 2. Wings: режим «Модель» из evo (подробности: `docs/EVO_REPORT.md`, раздел 2, шаги в 2.4). + Формат Bedrock `geo.json` + `animation.json`; 14 видов: angel, blue_dragon, dark, demon, dragon, easter, ghoul, + pulse, rocker, spider, trident, vulcano, wota (+ baby_dragon). angel/demon/dragon анимируются процедурно в коде + evo (`Internal29/41/42`), остальные из animation.json; wota с анимированной текстурой (64x640, 10 кадров по 150 мс). + Источник (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/*.{geo,animation}.json`, + `.../textures/cosmetic/wings/*.png`. Целевые пути: `assets/lovisual/wings/`, `assets/lovisual/textures/wings/`. + - [ ] `BedrockGeoParser` (общий с ножами) + запекание кубов в позиции/UV + - [ ] `BedrockAnimation` (keyframe, loop) и `ProceduralWingAnims` (angel/demon/dragon) + - [ ] `WingsRenderer.renderModel` (WORLD_TEXTURED[_DEPTH], якорь как у `drawWingSide`, подобрать знак Z и ориентацию) + - [ ] настройки в `Wings`: `wingSource` (Procedural/Model) + `wingModel` (14 ключей), tint/glow, lang en/ru + - [ ] тесты парсера, знаков pivot/rotation, UV, интерполяции +- [ ] 3. Модуль Knife (Melee Skins), раздел 3 отчёта, таблица классов в 3.7. 33 скина (21 геометрия, 12 перекрасок), + клипы draw/draw_1/inspect/inspect_1/melee_1..3/static_idle/run_start/run/run_end; ЛКМ чередует melee_1/2, + ПКМ melee_3, бег при спринте; first person и third person; звуки (94 ogg); шейдер holy. + Источник: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (index.json, *.geo.json, *.anim.json), `textures/cosmetic/melee/`, + `sounds/melee/`, `shaders/core/melee_holy.*`. Целевые: `assets/lovisual/knife/`, `textures/knife/`, `sounds/knife/`. + Порядок: (1) общий BedrockGeoParser, (2) клипы + `KnifeAnimationState`, (3) first person без рук, (4) руки игрока, + (5) third person (миксин `PlayerItemInHandLayer`), (6) звуки, (7) бег/варианты/holy, (8) UI выбора скина и сохранение. + Миксины: ввод (атака/ПКМ), `ItemInHandRenderer`, `PlayerItemInHandLayer`. +- [ ] 4. Быстрые модули (P1, размер S): TNTTimer, AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, + PlaceAnimation, GodRays (референс-шейдеры `shaders/core/godrays`), ProjectileTrails (на нашем `Trails`), ArmorColor. +- [ ] 5. Дальше (P1-P2): DeathEffects (распад на части, шейдер `death_blast`), Blizzard, ElytraTrails, TotemSound, Sounds, + InventorySort, StorageTracker, ConsumeESP, CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. + P3: Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. + Пропустить: GuiPresence (нужен бэкенд evo). Полный список из 29 модулей с оценкой: отчёт, раздел 1.2. +- [ ] 6. Лицензия: `ref/evo/README.md` объявляет код и ассеты evo проприетарными. Перед релизом заменить на свои модели/ + звуки или получить разрешение автора; до тех пор пути из отчёта только для локальной отладки, в репозиторий + ассеты evo не коммитить. ### Не проверено вживую / открытые вопросы From 8d9a2ad0d69d30ffe7c1792907abbffe840871f5 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 10:01:37 +0200 Subject: [PATCH 04/10] feat(mod): ProjectileTrails, KillEffect death modes (Shatter/Ascend/Explosion/Disintegrate), TNT fuse bar; compact TODO Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ --- mod/TODO.md | 844 ++---------------- .../hud_element_vanilla_tooltips.json | 1 + .../combat/prediction/Predictions.java | 4 + .../render/PredictionsHudRenderer.java | 11 + .../effects/hits/DeathEffectSettings.java | 39 + .../visuals/effects/hits/KillEffect.java | 51 +- .../visuals/effects/hits/death/DeathBody.java | 54 ++ .../effects/hits/death/DeathEffectHelper.java | 178 ++++ .../visuals/effects/hits/death/DeathMesh.java | 94 ++ .../effects/hits/death/DeathPhysics.java | 137 +++ .../hits/effects/FallingLavaHelper.java | 28 + .../projectiletrails/ProjectileTrails.java | 95 ++ .../effects/projectiletrails/TrailRibbon.java | 99 ++ .../effects/projectiletrails/TrailStore.java | 83 ++ .../resources/assets/lovisual/lang/en_us.json | 49 +- .../resources/assets/lovisual/lang/ru_ru.json | 49 +- .../effects/hits/death/DeathPhysicsTest.java | 81 ++ 17 files changed, 1097 insertions(+), 800 deletions(-) create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/DeathEffectSettings.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathBody.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathEffectHelper.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathMesh.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysics.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/effects/FallingLavaHelper.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysicsTest.java diff --git a/mod/TODO.md b/mod/TODO.md index 55f4f12..3ecd2d4 100644 --- a/mod/TODO.md +++ b/mod/TODO.md @@ -1,770 +1,74 @@ -# LoVisual Mod (combatant-client-26.2) — план переписи - -Мод — форк Combatant Client, переписываем под LoVisual. - -## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат) -- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга -- **Макс. 250 строк на файл** (было >1500) — превышение = разбить на модули -- **Макс. 4 файла на папку** — больше = новая подпапка по смыслу -- **Публичный API можно менять свободно** (решение владельца 2026-09-05): - KISS важнее обратной совместимости; при рефакторинге разрешено менять - сигнатуры, удалять делегирование и мёртвый код, обновляя вызывающих -- **Коммиты — на английском** (решение 2026-09-23, действует с этого момента - вперёд; более старые коммиты на русском не переписываем) -- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле -- **Никаких this-escape** — конструктор не публикует `this` -- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel -- **Без пустышек** — файл без реализации не создаётся -- **Каждый этап компилируется** — `./gradlew build` перед переходом к следующему -- **Тесты обязательны** для новых модулей (JUnit) - ---- - -## СТАТУС ПРОБЛЕМ (что вижу сейчас) - -### 1. Ребрендинг не закончен (пункт 2) -- 288 файлов / **1464 упоминания** `combatant` в main-коде -- Классы: `CombatantRenderSystem`, `CombatantRenderEngineBootstrap`, `CombatantRenderPipelines`, `CombatantVulkanBackend`, `CombatantFrameGraph`, `CombatantTerrainVertex` и др. -- Заголовки файлов: «This file is part of the Combatant Client distribution» / копирайт изначального автора - -### 2. Файлы-гиганты (пункт 3) — 12 файлов > 1500 строк -| Файл | Строк | -|------|-------| -| render/engine/renderer/Renderer2D.java | 3379 | -| features/gui/chat/BetterChatRenderer.java | 2870 | -| features/gui/clickgui/settings/UnifiedSettingRenderer.java | 2798 | -| features/module/modules/player/Scaffold.java | 2416 | -| features/module/modules/combat/AttributeSwap.java | 1922 | -| features/gui/clickgui/ClickGuiRenderer.java | 1900 | -| features/module/modules/visuals/NameTags.java | 1791 | -| features/module/modules/combat/ElytraTarget.java | 1711 | -| features/gui/hud/draggable/impl/TargetHud.java | 1687 | -| features/module/modules/visuals/ESP.java | 1520 | -| features/module/modules/combat/AutoCrystal.java | 1516 | -| features/gui/hud/nondraggable/impl/CustomBar.java | 1496 | - -### 3. Runtime / JarReplacement (пункт 4) -- `runtime/` (ClientRuntime, RuntimeGate, ClientRuntimeState, SOFT_PANIC/JAR_REPLACEMENT_PANIC, shutdown/resume-участники) -- `runtime/loader/` (JarReplacementCoordinator, LoaderBridge, PanicOnlyJarReplacementCoordinator) -- **Моё предложение: выкинуть.** Живая замена jar в памяти — источник classloader-утечек, хрупкая, для клиента не нужна (обновление делает лаунчер). Заменить на простой Lifecycle. - -### 4. Security (пункт 7) -- `features/security/BackdoorProtection` — на деле защита от SSRF/translate-инъекций (порт ExploitPreventer, MIT). Полезное, но `enum`-синглтон со статикой. -- `features/relations/StaffTracker` + `StaffHeuristicsMatcher` — детект «стафа» по эвристикам TAB. Сомнительно, решить оставить/выкинуть. - -### 5. Тесты (пункт 6) -- 5 тестов на ~213k строк. Добавлять по мере переписи модулей. - ---- - -## Фаза 1: Ребрендинг (пункт 2) -- [x] **Восстановлен `config/`-пакет** (55+3 файла) из `ref/zip/combatant-client-26.2 (1).zip` — он отсутствовал в форке, без него проект не собирался. Переименован `combatant.client.config` → `dev.loki.lovisual.config`. `compileJava` ✓ -- [x] Заменить `combatant` → `lovisual` в main-коде (1464 вхождения, 288 файлов) -- [x] Переименовать классы `Combatant*` → `LoVisual*` (23 шт., включая `IrisCombatantFrameHooks` → `IrisLoVisualFrameHooks`) -- [x] Заголовки файлов → LoVisual, пересобрать копирайты (GPLv3 форка) -- [x] Проверить `fabric.mod.json` / `lovisual.mixins.json` на остатки -- [x] Проверить строки/сообщения/логи на «combatant» -- [x] JNI-имена в native: `Java_combatant_client_` → `Java_dev_loki_lovisual_` (34 шт.) + FindClass-пути - -### Проверка: `rg -i combatant src` → 0 ✓, `./gradlew build` ✓ (1406 файлов изменено) - -## Фаза 2: Выкинуть Runtime/JarReplacement (пункт 4) -> **Вместе с рантаймом удалить и модуль Panic** (`features/module/modules/misc/Panic.java`), -> интерфейс `RuntimeControlModule`, команду `RuntimeCommand` и все ветки на них -> в `ModuleManager`/`Module`. Panic — единственная реализация `RuntimeControlModule`, -> без него они становятся мёртвым кодом. -- [x] Удалить `runtime/` и `runtime/loader/` (14 файлов) -- [x] Удалить `Panic.java` + `RuntimeControlModule.java` + `RuntimeCommand.java` -- [x] Убрать ветки `instanceof RuntimeControlModule` и `tickRuntimeControllers()` из ModuleManager/Module -- [x] Убрать `RuntimeGate` из точек входа (LoVisual.java, модули, рендер) — заменён на `Lifecycle` -- [x] Заменить на простой Lifecycle-класс (`dev.loki.lovisual.Lifecycle`: INIT → ACTIVE → SHUTDOWN, гейты `canRunRender/Hud/ShaderBridge` + `RenderResourceReadiness`) -- [x] Panic-фичи удалены; `ModuleManager.disableAll()` оставлен как простая утилита -- [x] Мёртвые cleanup-методы удалены (`shutdownForRuntime`, `clearRuntimeReferences`, `VanillaChatRuntimeCleanup`, i18n-ключи panic/runtime) - -### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0 - -## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА -> Стратегия: публичный фасад исключён из лимита ≤250 (делегирует ~все публичные методы), -> вся логика вынесена в package-private final-хелперы в том же пакете (плоско), -> ≤250 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/ -> AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно. -- [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors, - Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures) -- [x] BetterChatRenderer (2870) → фасад 210 + 21 хелпер (LayoutEngine, Viewport, FrameRenderer, - MessageRenderer, GlyphRenderer, InputRenderer, Suggestion*, InputHandler, MouseClick, - ContextMenu, Tooltip, Draw/Font/EditBox/Segment/CommandMerge + 3 model-файла) -- [x] UnifiedSettingRenderer (2798) → фасад 208 + 24 хелпера (по типам настроек + цвет-редактор - из 9 классов: Renderer, SquareRenderer, PresetRenderer/Handler, HexInput/State, Drag, Layout, Math) -- [x] ClickGuiRenderer (1887) → фасад 542 + 15 хелперов (Input, Keyboard, RenderEngine, Lifecycle, - Screen, TextBatch, DrawHelpers, KeyBind, Editor, PickerIcon, TopLayer, TabBar, TabState + Model + Fonts) -- [x] NameTags (1791) → фасад 438 + 11 хелперов (Renderer, Plate, LayoutEngine, Face, Equipment, - Opponent, Effects, LabelBuilder, EnchantFormatter, Cache + Model) -- [x] TargetHud (1655) → фасад 340 + 11 хелперов (Renderer, TargetResolver, Panel, Face, Content, - Text, Arc, Equipment, Effects, Formatter + Model) -- [x] ESP (1538) → фасад 269 + 11 хелперов (EntityCollector, ShaderRenderer/Mask/Color, 2D/3D, - SearchOverlay, Outline, MeshBuilder, ColorUtil + Model) -- [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model) - -### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤250, фасады исключены; -### 0 вызывающих файлов изменено во всей Фазе 3 - -### Долг (файлы >250 вне списка гигантов, в т.ч. новые фасады): -### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449, -### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116, -### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042, -### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441, -### а также многие HUD/module/rendering-файлы 250–800 строк. Разбирать по желанию. - -## Фаза 3.5: Удаление читерских модулей (легит-направление) -> Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются. -- [x] combat: удалены AttributeSwap, AutoAnchor, AutoAttack, AutoBed, AutoBow, AutoCrystal, Backtrack, - Criticals, ElytraTarget, Hitbox, KillAura, KTLeave, MaceKill. **Оставлены:** ProjectilePuncher, - PvpCooldowns, Reach, RubberHand, SpearAssist, TPSSync -- [x] movement: удалены ВСЕ модули (AirJump, AutoDodge, ElytraFly, ElytraHelper, Flight, Freeze, - InventoryMove, NoFall, NoPush, NoStun, Parkour, Phase, ReverseStep, SafeWalk, Speed, Sprint, - Step, Strafe, SuperFirework, TargetStrafe, Timer, Velocity, WindJump) — 22 файла -- [x] player: удалены AirPlace, AutoArmor, AutoEat, AutoTotem, Scaffold, XCarry. - **Оставлены:** AutoTool, ClickPearl, FakeLag, NoDelay, NoInteract, ShitDropper -- [x] Осиротевшие хелпер-пакеты: удалены `combat/autocrystal/` (17), `combat/autobed/` (9), - `combat/autoanchor/` (9), `util/block/scaffold/` (10), `BlockPlacer` + тест ScaffoldPlacementGateTest -- [x] Enum-заглушки: `KillAura.RaycastMode/RotationTiming/AngleSmoothMode` → локальные enum в - ProjectilePuncher/Reach; `Criticals.SelectionMode` → удалено; `Hitbox`-зависимость убрана из ESP -- [x] ESP переписан под легит: новые настройки `only_visible` (LOS-луч от камеры), `skip_invisible`, - `only_armor` (без брони не подсвечивать) — по умолчанию `true`, только для игроков - -### Проверка: `./gradlew build` ✓, тесты ✓ (13), остатки только в комментариях/строках - -## Фаза 4: Security (пункт 7) -- [x] BackdoorProtection: убрать `enum`-синглтон, сделать инстансный сервис, ленивый `Minecraft.getInstance()` -- [x] Решить судьбу StaffTracker/StaffHeuristics — согласовать и выкинуть или переписать - (решение: StaffHeuristics удалён, StaffTracker+StaffData оставлены, переведены на INSTANCE-паттерн) -- [x] Покрыть логику фильтров тестами (BackdoorProtectionTest, 11 тестов) - -## Фаза 5: Модули — нормализация под правила -- [x] Прочесать все 131 модуль: убрать `static final Minecraft` в полях (6 шт. уже найдено) - (LegacyHudNotifier, ScissorFunction, ShitDropper, ProtocolUtil, CooldownRender, ClickGuiRenderer.MC - + 7 файлов-потребителей ClickGui-пакета — всё переведено на ленивый `Minecraft.getInstance()`) -- [x] Проверить модули на `System.currentTimeMillis()` → заменить на игровое время - (16 файлов модулей → новый `util/time/GameClock.millis()`, откат на стенное время вне мира) -- [ ] Разнести addon/events/mixininterface по смыслу — ОТЛОЖЕНО: blast-radius 54/41/88 файлов-импортёров, - чистый import-churn без функциональной ценности; делать только отдельным скриптовым проходом - -## Фаза 6: Тесты (пункт 6) -- [x] Юнит-тесты: EventBus (12), конфиг (values: NumberValue/Boolean/String/Set/ItemIdSet/BooleanMap, - ModeValue, EnumValue, RGB/RGBA color; profile: DiffEngine, ValueCodec), - утилиты (LegacyTextUtil — 12). Игровые классы без моков: тесты только на чистую логику. -- [x] По мере переписи модулей — тест на каждый (Текущий итог: 292 теста, все зелёные) - -## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем -> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи, -> переписываем под наши правила; у Delta 114 модулей, у нас 63). -> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog, -> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага, -> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src. - -### Решения по обсуждению (2026-09-05) -- [x] Wasted — НЕ делаем -- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт) -- [x] DeathCoords — делаем -- [x] ItemScroller / Arrows — НЕ делаем -- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты) -- [x] Правило папок: максимум 4 файла (было 3) - -### Перенос (список пользователя) -- [x] **StreamerMode** (misc) — маскирует ники-подобные токены и названия серверов в чате - (общая точка NameProtect: ChatComponentMixin.addMessage), скины заменяются на - стандартные (AbstractClientPlayerMixin.getSkin). Чистая логика — `util/text/NameMasker` - (тест NameMaskerTest). -- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q), - опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета. -- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen). -- [x] **AutoLeave** (player) — выход по триггерам: низкое ХП, N игроков в радиусе - (друзья игнорируются опцией), задержка + предупреждение. Выход через - `mc.disconnect(null, false)`; чистая state-machine `LeavePolicy` (AutoLeaveTest). -- [x] **AutoAccept** (player) — авто-ответ на ТП/клан-запросы: паттерны фраз (SetValue), - команда ответа, «только друзья» (PlayerRelations), задержка, кулдаун ≥500мс. - Пакетный путь: PvpChatEvent (handleSystemChat/handlePlayerChat), отправка - `connection.sendCommand(...)`. Чистый парсер `TeleportRequestParser` (AutoAcceptTest). -- [x] **SoundReducer** (player, ~42) — РЕШЕНО: развитие NoSound, а не новый модуль. - Режимы Mute (старое поведение через ClientPacketListenerMixin) и Reduce — - новая громкость 1–100% через SoundEngineMixin (@Redirect getVolume в - SoundEngine.play). Чистая математика `NoSound.resolveScale` (NoSoundTest). -- [x] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя) -- [x] **ThirdPerson** (player) — свободный обзор от 3-го лица по удержанию бинда (L, - настраивается), движение игрока не поворачивается. MouseMixin.turnPlayer копит - orbit-yaw/pitch, CameraMixin.update (TAIL) ставит камеру за игрока по орбите - (+ cull-frustum). Орбиты сбрасываются, когда бинд отпущен. -- [x] **UseTracker** (player) — уведомления: мои тотемы (N осталось), тотемы других, - эффекты оппонентов (группа: invisibility/speed/strength/regeneration/fire_res/weakness). - Пакетный путь: ClientboundEntityEventPacket (event 35) + ClientboundUpdateMobEffectPacket. - Тест UseTrackerTest. -- [x] **NoServerPack** (misc) — авто-отклонение принудительного серверного ресурспака - (спрашивать/отклонять всегда). Точка: ClientCommonPacketListenerImplMixin. -- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations). -- [x] **TapeMouse** — ОСТАВЛЕН (решение владельца 2026-09-08): автоклик по таймеру - (Attack/Use) для AFK-ферм, интервал, опция «только при зажатии». Считаем - допустимым исключением из Фазы 3.5 по явному решению владельца. - -### Кандидаты из отчёта (не в списке пользователя — обсудить) -- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно. -- [x] **ItemScroller** (misc) — shift-перекладка предметов колёсиком в контейнерах: - QUICK_MOVE клик по слоту под курсором, иначе слот хотбара (опция). - Точка: AbstractContainerScreenMixin.mouseScrolled; чистая логика - `ScrollMovePolicy` (ScrollMovePolicyTest). -- [x] **Arrows / off-screen индикаторы** — ОТМЕНЕНО (решение владельца 2026-09-08). - -### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓ - -## Фаза 7.5: Команды — префикс % + автокомплит -- [x] Префикс команд сменён `@` → **`%`** (старый `@` принимается как legacy). - Источник истины — `features/command/CommandPrefix` (CHAR/STRING/isCommandLike/strip); - вызовы обновлены: CommandManager.handle/suggest, ScreenMixin (RunCommand), - BetterChat (TabHandler, SuggestionState, SuggestionRenderer, ContextMenuHandler), - usage-строки и RunCommand-ссылки всех команд (help/modules и др.). -- [x] Аргументный автокомплит дополнен usage-подсказками: если `suggest()` команды - не дал вариантов на позиции — показывается подсказка вида `%toggle ` - (`features/command/UsageHints`, парсит `<...>`/`[...]` из usage-строки). - Существующий UI автокомплита BetterChat (стрелки/Tab/клик) работает как раньше. - -### Проверка: `./gradlew build` ✓, `./gradlew test` ✓ (CommandPrefixTest, UsageHintsTest, -### CommandDiscoveryTest обновлён) - -## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН -> Правило ≤250 уже есть (Фаза 1–3), но долг: ~280 файлов >250 строк. -> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) / -> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов. - -- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527, - DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181, - HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042 -- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤250, 0 изменений вызывающих, - `./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу -- [ ] Facades >250 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам) -- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша - -### Проверка: `./gradlew test` зелёный - -## Фаза 8.5: Аудит структуры (правила ≤250 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ -> Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк). -> **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать, -> должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком. -> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤250, 0 правок вызывающих, -> билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже. - -### Итоговая картина -- **Файлы >250 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140 -- **Папки >4 файлов: 86 шт.** -- Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 | - features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное - -### 8.5.1 Папки >4 файлов — топ должников (разбить на подпапки по смыслу) -- [x] `mixins/` — 108 файлов → leaf-пакеты (entity/blaze3d/input/world/network/gui/hud/render), - mixins.json перегенерирован, FQN-строки LoVisualMixinPlugin обновлены. Коммиты 94d5af7, 3c76817. -- [x] `mixins/accessors/` — 45 → 12 групп (entity/renderer/world/state/packet/inventory/...). Коммит 3c76817. - Также разбиты iris/sodium/security на подпакеты. -- [x] `features/module/modules/visuals/` — 67 → esp/nametags/hats/effects/weather/scene/camera/ - player/items/fx + lyrics/lrc. Конфиг-id НЕ менялись (проверено). Коммит 073ac26. -- [x] `features/gui/clickgui/settings/` — 51 → core/bool/mode/slider/text/text-list/group/bind/ - color(+render/preset/hex)/i18n/render/skin/owner/cooldown/util. Коммит 9db1fec. -- [x] `features/gui/hud/draggable/impl/` — 40 → stats/metrics/status/combat/panels/targethud(+/render, - render/parts)/scripted + hudnotifier/model, targethud-модель свёрнута в класс с record-ами. Коммит 6b89e40. -- [x] `events/impl/` — 32 → input/movement/world/network/entity/pvp/render/player; - events/ верхний уровень: core(Event,EventBus,Events) + meta(EventHandler,UsedImplicitly). -- [x] `features/gui/chat/` — 31 → `render/`, `suggestions/`, `menu/`, `model/`. -- [x] `render/engine/uniform/impl/` — 28 → по потребителям (`uniform/impl/pipeline/`, `uniform/impl/effects/`). -- [x] `render/engine/renderer/ui/` — 28: верхний уровень разнести по уже существующим - `draw/`, `runtime/`, добавить `blur/`, `batches/`. -- [x] `render/engine/text/` — 24 и `text/backend/` — 12 → `text/` разбить на `text/render/`, - `text/layout/`, `text/msdf/`. -- [x] `render/engine/renderer/ui/runtime/script/` — 23, `ui/draw/` — 23 → внутри по 4-файловым кластерам. -- [x] `features/gui/hud/nondraggable/impl/` — 20 (+`impl/tab/`) → `impl/overlays/`, `impl/tablist/`. -- [x] `features/gui/clickgui/` — 20 на верхнем уровне → всё в существующие `layout/`, `sections/`, - `picker/`, `protocol/`, `util/`. -- [x] `render/engine/command/` — 17, `render/helpers/` — 16, `features/command/impl/` — 16, - `config/values/` — 16 → по 2-3 подпапки (типам: `values/numeric/`, `values/color/`…). -- [x] `render/engine/profiler/` — 14, `modules/player/` — 14, `features/gui/hud/` — 14, - `addon/` — 14 — средние: раскидать при касании. -- [x] Хвост (5–13 файлов, ~55 папок): `util/pvp` 13, `util/projectile` 13, `render/iris` 13, - `config/profile` 13, `features/module/misc` 12, `features/module` 12, `config` 12 и т.д. — - ЗАКРЫТ выделенным проходом (коммиты 8846d53…303b17e, волна 8.5.1 завершена). - Снимок после волны: 0 папок >4 файлов в `src/main/java` (скан 2026-09-10). - Замечания: command-discovery рекурсивный (ClassGraph acceptPackages), конфиг-id - и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными, - расширены до public/protected; svgMsdfTool-импорты починены после split svg. - -### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА -> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк -> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53 -> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон, -> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤250». -> Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка: -> Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные -> классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`. -- [x] Renderer2D 2179→1597 → ГОТОВО 7b848ddd+464f7868+098c5515 (2026-09-25): ФАСАД; - далее 1597→475 наследованием (волна 2026-09-25 ниже) - (исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы - совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners, - −115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >250 нет), - хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓ -- [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/` - (Screen 132, Controller 250, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100 - + render/cards/ 178/166), все ≤250, build+test ✓ -- [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) + - render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) + - render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓ -- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤250 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/` -- [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами -- [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести -- [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно -- [x] WorldParticles 991 → ГОТОВО 1d757a6+5a463d7e: 999→821→260 (фасад) + ambient/render/ 3×≤182 - (Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) — - типы частиц в `visuals/effects/ambient/` -- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!) -- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/ - (Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183 - + render/cards/ 79/88), все ≤250 — по образцу AltManager -- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий) -- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы -- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния -- [x] UiMeshGeometry 954 → ГОТОВО sub-agent: 956→99 + 4×≤135 — по примитивам геометрии -- [x] BetterTooltips 953 → ГОТОВО 0b8ea41: 960→181 +4×≤199; OrderedUiBatcher 935 → ГОТОВО 6e0bf7f: 944→188 (→ batches + blur), Scoreboard 924, - Rain 893 → ГОТОВО sub-agent: 903→159 +4×≤162; CompactHudStatModel 893 → ГОТОВО 8979140: 894→125 +4×≤175; TazikHat 859 ✓ ✓ закрыт портированием в - Cosmetics built-in (2026-09-11, см. Фазу 7), - ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166, - GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) + - mixins/render/main/handlers/ 4×≤250 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост) -- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤250 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791, - HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739, - Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701, - Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673, - CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663, - DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617, - ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.) -- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a, - MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e, - CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы - +bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7, - CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435. - bars/ закрыт: все файлы ≤250, impl/ = 3 файла. -- [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше, - 7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d - (SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены; - логика ≤26 строк, фасад-исключение), build+test ✓. -- [x] Волна 2026-09-25 (наследование фасада): Renderer2D 1597→475 (leaf), цепочка - Renderer2D extends Item(124) extends Glass(183) extends Shapes(313) extends - Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся - (0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset - работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит; - Base.self() для this→Renderer2D-делегатов; семейства >250 — фасад-части - (исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+ - 9fffc58d, build+test+checkstyle ✓. - -### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить» -- [x] Волна 2026-09-30 (топ-4 по объёму, выбор владельца): каждый файл — отдельный - коммит, фасад + хелперы, публичный API и вызывающие не тронуты: - ConfigDiffPanelComponent 393→167 (фасад + `config/diff/` - HeaderRenderer/BodyRenderer/Scrollbar) 7e8b6b64; - RotationManager 391→199 (фасад + `rotation/internal/` - RotationState/RotationStepper/RotationPackets, @EventHandler-методы остались - на фасаде — регистрация не менялась) 3e57542b; - ScriptedTriangulatorHudPanel 386→189 (записи Panel/Palette остались - вложенными → `ScriptedTriangulatorHudPanel.Panel` у вызывающих работает, - рендер → ScriptedTriangulatorRenderer, пропсы/сигнатуры → `scripted/panel/`) - 76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу - BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов) - 71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге; - файлов >250: 155 → 151 -- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом - разбивка на ≤250, вторым — целевое изменение (иначе diff-каша, правило Фазы 8) - -### 8.5.4 Порядок и критерии готовности -- [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) → - 8.5.2 гиганты >800 → далее по убыванию. Каждая папка/файл = отдельный коммит. - → 8.5.1 ЗАВЕРШЕНА (2026-09-10): 0 папок >4 файлов. -- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и - ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов - не трогают string-literals id — проверять grep'ом после moves. -- [x] Критерий закрытия для старта 9.2: ноль файлов >250 в путях, которые трогает Optimize - (Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect, - MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем - 244/86 — долгий хвост, 9.2 им не блокируется. - → 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓ - (glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >250: - TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами - (targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) → - исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ. -- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики). - -## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН -> Диагноз: у клиента есть «налог каждый кадр», который платится даже при -> выключенных модулях (друг: просадка FPS на чистом конфиге, Windows). -> Решение владельца: мёртвые грузы — ЧИНИМ в коде; авто-деградации по FPS НЕ ДЕЛАЕМ; -> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы). - -### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет -- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass` - (`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors` - (`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на - submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с - Windows-драйвером, несколько мс/кадр. Фикс: проверять только если - `mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом; - в релизе — no-op. -- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean - (`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()` - заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean. -- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) — - `LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт - `needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth` - (`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`, - `LoVisualVisuals:79`, `WorldPassCompiler:174`. -- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource` - (`:116-123`): полноэкранный color-copy делается до проверки - `liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда - blur реально запрошен. -- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site - каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290` - (`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check. -- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре - (`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался. - -### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии -> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) — -> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >250 строк. -> Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS. -> Пока активен — движок и HUD работают в лёгком режиме. -- [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит - `checkFolderLimit`=4 файла в `misc/`), 4 bool-тумблера (default on), тест `OptimizeTest`. - Отклонения от плана при реализации: - - `no_glass` — **9246e010**: не мутим bgEffect (конфиг элементов неизменяем) и не - ищем `prepareSharedBlurInternal` (метода больше нет). Гейты: `Blur2D.source()` - → null при no_glass (вместе с `FpsOptimizer.isGlassDisabled` → все - blurRect/glassBlurRect мимо) + ранние return в - `UiBlurResources.requestLiquidGlassBlur/requestBeforeNextShapeClip` - (флаги не ставятся → `captureWorldSource` early-out → нет полноэкранного copy, - kawase и prewarm). Побочный эффект: гасит и низкоконтрастный blur текста - (осознанно — «все glass/blur off» по замыслу тумблера). - - `lite_post` — **52463246**: MotionBlur `SHADER_TAPS=11 → LITE_TAPS=5` - (план «15→5» устарел — тапов уже 11), `historyValid = hasHistory && !lite` - (temporal-история off = ещё ~10 neighborhood-fetch'ей в шейдерный early-out; - цена: хвостов истории нет, остаётся чисто пространственный блюр). - DoF: `taps = lite ? LOW : quality` (LOW=8; план «32→8» устарел — квалити 8/14/22). - - `lite_sky` — **c403cb53**: `SkyboxShaderPasses.LITE_SKY_LAYER_MASK=0xF00F` - (базовые слои 0-3 + звёзды 12-15, чистит nebula/curtains/bursts/polar/water/…), - `layerMask &=` при включённом тумблере; в `reimagined_skybox.frag` 4 - безусловных fbm + bursts-блок обёрнуты в layer-гейты; pulsar/thunder fbm - 4→2 октавы через `u_Thunder.w` (ранее всегда 0). Тест `SkyboxShaderPassesTest`, - все 3 шейдера проходят `glslangValidator -S frag`. - - `lean_hud` — **8746074d**: `GradientTexts.horizontalFade` → plain-текст (без - per-glyph `new String` и gradient-лямбд; marquee-clipped — clip остаётся, - fade-градиенты гаснут) в Custom и Vanilla-реализациях; Fps digit-roll off - (`FpsAnimator.widerText/digitProgress` → settled-прогресс). - Не в scope: `GradientTexts.gradient/quadGradient` (визуал списка модулей), - аллокация в `HorizontalFade.glyphAdvance` (её просто обходит lean-гейт). -- [x] Механизм «как движок узнаёт» — ГОТОВО ade82f06: `render/engine/optimize/ - OptimizeState.java` (enum-utility, 4 volatile boolean, `apply/reset`, чистый - статик без состояния модуля), движок читает геттеры, модуль пушит в onEnable/onFrame. -- [x] Взаимодействие с 9.1 — ГОТОВО: фиксы 9.1 не завязаны на Optimize и продолжают - работать всегда; Optimize только добавляет гейты сверху. -- [x] i18n en_us + ru_ru — ГОТОВО ade82f06: `module.optimize.*` + `setting.optimize.*` - (4 ключа, симметрия en/ru проверена). -- [x] Нотификация при первом включении — ГОТОВО ade82f06: once-флаг в инстансе модуля + - `Notifier.info("Optimize: " + summaryOf(...))` со списком включённых тумблеров. - -### 9.3 Проверка -- [x] `./gradlew build` после каждого пункта; `./gradlew test` зелёный — выполнялось - после каждого из 5 коммитов 9.2 (итог: build+test зелёные на 8746074d). -- [x] Порядок: 9.1 (по одному пункту на коммит) → 9.2 — 9.1 закрыта (коммиты выше), - 9.2 закрыта ade82f06..8746074d. -- A/B-мерка до/после 9.1 на чистом конфиге — УБРАНА из плана (решение владельца - 2026-09-25): требует запуска Minecraft на Windows, задачей не является. - -## Фаза 10: Заход по референсам из Downloads (2026-09-15) -> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения — -> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого. - -- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`, - референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`. - Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица - камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up - вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех» - брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась - максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры, - перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все). - Коммит e7e0ff2. -- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).** - Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже - является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass, - drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас - уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер - `assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как - `PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный - `WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна. - Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ - самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя: - мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками - Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only, - учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер). - У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш - wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если - захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой - к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality- - пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее. -- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.** - Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` + - `wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным - рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере - глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция. - Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги: - 1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины, - шумная/блочная нормаль -> дрожащий край лужи; - 2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` — - умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение - волны вместо её наклона. - Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе), - рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент - SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено - в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль - `visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо - от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra - -> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed, - Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при - одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде). - Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в - нашем движке в этом заходе) — можно добавить отдельно, если понадобится. -- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).** - Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer` - используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом - (кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника - не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке - удара), реализована через уже существующий движок: обычные аддитивные квады через - MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без - захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble - в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/ - impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер — - расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по - контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓. -- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.** - Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над - СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей - свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор - НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего - jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через - `OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**: - лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см. - `heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip. - Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags), - `HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/ - gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems - (дефолты как у референсного пресета "HMI"), проброс ItemStack через - HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные. - -### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен - -## Фаза 11: Аудит attack-визуалов — sacura 1.21.4 / GodWeer-26.2 (2026-09-22) -> Референсы распакованы в `ref/clients/sacura-1.21.4` и `ref/clients/godweer-26.2`. -> Вывод по большинству эффектов: у нас уже полноценнее (больше режимов, единый render -> engine с пайплайнами вместо сырого GL). Ниже — конкретные пробелы, которые реально -> стоит доделать; всё остальное сравнённое (HitEffect, KillEffect, JumpCircles, -> Hitboxes, ItemPhysic, ViewModel offsets) уже на уровне или выше обоих референсов. - -- [ ] **ViewModel — `Custom` swing-режим.** sacura (`SwingAnimationsModule`) имеет режим - "Кастомная" с explicit слайдерами угол/наклон. У нас 8 фиксированных пресетов - (Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust), но нет свободно настраиваемого. - Также посмотреть на sacura-режимы "Возвращение"/"Возвращение к себе"/"Круговая" — - возможно стоят портирования как доп. пресеты, если визуально отличаются от наших. -- [ ] **HitEffect — опциональный distortion/glow post-process слой.** godweer - (`HitEffectRenderer`) кроме частиц кладёт screen-space distortion+glow слайдеры. - У нас частицы (Bubbles/Wave/Impact) есть, post-process слоя над ними нет. - Паттерн `PostProcessPass` уже есть (см. `TotemFX`), можно переиспользовать — - но не блокирующая, это чистый апгрейд поверх уже более богатой системы. -- [ ] **JumpCircles — easing на появление/исчезновение.** sacura использует `EaseBackIn` - для пружинки при спавне/затухании круга. Проверить `render/engine/math` — если - easing-утилита уже есть, подключить к текущему fade вместо линейного. -- [ ] **KillEffect — through-walls поведение.** godweer (`PopEffect`) имеет явный toggle - "сквозь стены" для beam/взрыв эффектов. Проверить DepthMode у Lightning/Orthodox - beam в нашем KillEffect — сейчас режется окклюзией или нет; если режется и это не - специально — добавить toggle по аналогии. -- [ ] **Trails — живое сравнение с sacura `TrailsModule`.** Не разбирался по коду в этом - заходе (визуальный эффект, решение лучше принимать посмотрев обе стороны вживую - в игре, а не по исходникам). - -### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта, -### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые, -### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle). - ---- - -## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска - -> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить). -> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`); -> всё, что ниже помечено «не проверено вживую», видно только в игре. - -### Сделано (код + сборка + тесты зелёные) - -- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели, - линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение. - Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест). - `TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в - `targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`. -- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест, - Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`, - круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`), - 12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`. -- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен, - лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет, - количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего). -- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout` - (`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика - `hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы. -- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`, - `VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом - микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar. -- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR` - (rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом. - Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были, - 6-й параметр в длинной форме это `softness`, а не alpha. -- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin` - подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `` - показываются серым текстом (`command/suggest/SuggestionPlan` + тест). BetterChat идёт своим путём. - Раньше подсказки работали только в BetterChat. -- [x] **Починка запуска клиента** (игра не стартовала с августа, все пять дефектов найдены по логам runClient): - 1. MixinExtras 0.5.4 падал на `@Redirect(at=...)` (ClassCastException при трансформации `Entity`): - добавлен `mixinextras-fabric:0.5.5` (`gradle.properties: mixinextras_version`), вшит в jar через `include`. - 2. `ServerPolicyClient`: `registerReceiver` -> `registerGlobalReceiver`. - 3. Классы-помощники лежали в пакете миксинов (Mixin запрещает их загрузку): вынесены в - `render/gamerenderer/` и `mixinhandlers/{player,hud,network}/`, сделаны public. - 4. `Themes`: `STORE` создавался раньше таблицы пресетов (NPE в `ThemeStoreIO`): порядок инициализации. - 5. `MainSettingsComponent.rebuildSettings`: `addAll` в неизменяемый список (падал ClickGUI по RShift). -- [x] 2026-10-01 все сегодняшние коммиты объединены в один по просьбе владельца. - -### Отзывы владельца после запуска (скриншоты) — СДЕЛАНО в коде, ждёт проверки в игре - -Итог 2026-10-01 (сборка и тесты зелёные, визуально не проверено): -- Тултипы: причина была в том, что пользовательский тултип, перекраска, фон и предпросмотр шалкера читали - собственные флаги и игнорировали переключатель модуля; теперь везде `isEnabled()`. Синие строки - категорий красятся акцентом темы (`TooltipColors`). Тема по умолчанию `Themes.THEME_DEFAULT = AMBER` - (сохранённый выбор пользователя не трогается). -- Описания: ~98 новых ru/en (62 модуля, 36 HUD), `HudElement.getDescription()`, тест `DescriptionCoverageTest`. -- ClickGUI: плашка с описанием над колонками (`ModulesMenuDescriptionRenderer`, `DescriptionLines`). -- Hero-меню: раскладка по измеренному тексту (`TextMetrics`, `HeroLayout`), кнопки с SVG-иконками - (`hero/paint/*`), orbs: подпись под кнопкой при наведении (`MainMenuHoverLabel`). -- Настройки меню: новая категория «Главное меню» (`MAIN_MENU`), `MainConfig.getMainMenuSettingDefs()`. -- Проверить в игре: положение и размер плашки описания, вид amber-тултипа, иконки и шрифты Hero-меню, - подпись orbs-кнопок, видимость настроек (`visibleWhen`) в категории «Главное меню». - -Задача А (форк, тултипы и описания): -- [x] **Тултип предметов (BetterTooltips) странный и не выключается.** При первом запуске у камня синие - строки-категории («Building Blocks / Natural Blocks») и тёмная панель; даже при выключенном модуле - стиль остаётся. Нужно: найти причину (миксин `mixins/hud/tooltips/TooltipRenderUtilMixin` - применяет стиль без проверки включённости / кэш `TooltipState`), при выключении тултип строго - ванильный, при включении аккуратный стиль. **Стиль по умолчанию должен быть `amber`, а не `classic`** - (`Themes.THEME_AMBER`); старые конфиги не ломать. -- [x] **Нет описаний модулей и HUD-элементов в настройках.** На всех карточках HUD/UI написано - «Module description is missing.». Нужно добавить описания ru/en для каждого `@ModuleInfo`-модуля и - каждого HUD-элемента (ключи `module..description` и HUD-аналог), проверить, что не осталось - пустых (тест или скрипт сверки id <-> lang). -- [x] **Описание функции при наведении в обычном ClickGUI** (колонки Combat/Player/Visuals/Other). - В области сверху по центру над колонками показывать описание функции под курсором (плашка в нашем - стиле, плавное появление, перенос/обрезка по ширине). - -Задача Б (форк, главное меню): -- [x] **Hero-меню «калово»**: заголовок налезает на подзаголовок и кнопки, нет иконок на кнопках, - мелкие и кривые шрифты. Эталон: референс Vectra (иконки слева в кнопках, акцентная Multiplayer, - тёмное стекло у остальных, пара Options/Quit внизу, карточка аккаунта слева сверху, виджет - времени справа сверху). Раскладку считать по измеренным размерам текста, а не по константам. -- [x] **Основное (orbs) меню тоже улучшить стилем** (типографика, тени, hover, появление). -- [x] **Настройки главного меню разбросаны и спрятаны** (`menuLayout`, `menuBg`, `menuBgUseTheme`, - `menuClockShowSeconds` в `MainConfig`). Сгруппировать в понятный раздел «Главное меню» в - настройках (ключи конфигов не менять, чтобы сохранённые значения не потерялись). - -### Правки после второго запуска (2026-10-01) - -- [x] `%tier `: не было `suggest` (подсказка показывала только серый ``): теперь ники игроков и режимы - (`vanilla|uhc|pot|nethop|smp|sword|axe|mace`). Остальные команды без `suggest` не имеют аргументов. -- [x] KillEffect Blood было видно сквозь блоки: пайплайн `WORLD_COLORED` без depth-теста заменён на `WORLD_COLORED_DEPTH`. -- [x] TargetESP: новый режим `swords` (8 мечей на орбите, пружинная физика, отдача при ударе, подсветка при хите). - `targetesp/sword/` (`SwordPhysics` чистая + 4 теста, `TargetEspSwords` рендер), текстура `particles/sword.png` - нарисована скриптом (пиксель-арт, окрашивается цветом палитры). Мечи рисуются поверх блоков (ESP, без depth). - Не проверено вживую: вид, размер, яркость свечения, текстура меча (при желании заменить на свою). - -### Фаза 13: разбор evo клиента (2026-10-01), план работ - -Отчёт: `mod/docs/EVO_REPORT.md` (29 модулей evo, которых нет у нас; крылья; ножи; HMI/Lua). -Лицензия: `ref/evo/README.md` объявляет код и ассеты проприетарными. Для релиза только свои модели/скрипты -или разрешение автора; формат (Bedrock geo/anim, HMI-Lua) стандартный, код пишем свой. -Порядок по просьбе владельца: сначала 1, потом остальное. - -- [x] 1. HoldMyItems Scripted на LuaJ (пункты 1-8 отчёта, частично): настройка `Режим` = Dynamics / Scripts / Both - (по умолчанию Dynamics, LuaJ грузится лениво). `held/lua/`: `HandScripts` (загрузка + горячая перезагрузка раз - в секунду из `config/lovisual/holdmyitems/{hand_pose,item_pose}.lua`, иначе встроенные примеры в - `assets/lovisual/holdmyitems/`), `HandFrame`; `runtime/`: `LuaSandbox` (только base/bit32/table/string/math, - без io/os/luajava/require/load/debug, лимит инструкций через хук), `LuaPreprocessor` (`global.x = v;` -> registry), - `ScriptSlot` (ошибка выключает только этот скрипт, лог один раз); `api/`: `M` (move/rotate с pivot/scale/push/pop/ - math), `P`, `I`, `Easings` (8 функций), без отдачи Java-объектов в Lua. Хуки в `ItemInHandRendererMixin`: - hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`. - (Хвост из 1-8 закрыт в пункте 1b ниже.) -- [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны - `hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`, - выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем - кадре); настройка «Всегда рисовать обе руки» (`always_hands`); `animator` (`ext/ModelAnimator` + миксин - `ItemModelAnimatorMixin` подменяет список квадов слоя, операции rotate/move/scale по диапазону индексов, pivot - в пикселях); `particleManager:addParticle{...}` с Lua-колбэком `tick` (`ext/HandParticles`, аддитивный BLOOM в - мире); `C:setCamRot/setCamPos` (`CameraScriptMixin`, действует 150 мс после вызова). Встроенные примеры для всех - скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/ - `using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков. - Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели. -- [ ] 2. Wings: режим «Модель» из evo (подробности: `docs/EVO_REPORT.md`, раздел 2, шаги в 2.4). - Формат Bedrock `geo.json` + `animation.json`; 14 видов: angel, blue_dragon, dark, demon, dragon, easter, ghoul, - pulse, rocker, spider, trident, vulcano, wota (+ baby_dragon). angel/demon/dragon анимируются процедурно в коде - evo (`Internal29/41/42`), остальные из animation.json; wota с анимированной текстурой (64x640, 10 кадров по 150 мс). - Источник (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/*.{geo,animation}.json`, - `.../textures/cosmetic/wings/*.png`. Целевые пути: `assets/lovisual/wings/`, `assets/lovisual/textures/wings/`. - - [ ] `BedrockGeoParser` (общий с ножами) + запекание кубов в позиции/UV - - [ ] `BedrockAnimation` (keyframe, loop) и `ProceduralWingAnims` (angel/demon/dragon) - - [ ] `WingsRenderer.renderModel` (WORLD_TEXTURED[_DEPTH], якорь как у `drawWingSide`, подобрать знак Z и ориентацию) - - [ ] настройки в `Wings`: `wingSource` (Procedural/Model) + `wingModel` (14 ключей), tint/glow, lang en/ru - - [ ] тесты парсера, знаков pivot/rotation, UV, интерполяции -- [ ] 3. Модуль Knife (Melee Skins), раздел 3 отчёта, таблица классов в 3.7. 33 скина (21 геометрия, 12 перекрасок), - клипы draw/draw_1/inspect/inspect_1/melee_1..3/static_idle/run_start/run/run_end; ЛКМ чередует melee_1/2, - ПКМ melee_3, бег при спринте; first person и third person; звуки (94 ogg); шейдер holy. - Источник: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (index.json, *.geo.json, *.anim.json), `textures/cosmetic/melee/`, - `sounds/melee/`, `shaders/core/melee_holy.*`. Целевые: `assets/lovisual/knife/`, `textures/knife/`, `sounds/knife/`. - Порядок: (1) общий BedrockGeoParser, (2) клипы + `KnifeAnimationState`, (3) first person без рук, (4) руки игрока, - (5) third person (миксин `PlayerItemInHandLayer`), (6) звуки, (7) бег/варианты/holy, (8) UI выбора скина и сохранение. - Миксины: ввод (атака/ПКМ), `ItemInHandRenderer`, `PlayerItemInHandLayer`. -- [ ] 4. Быстрые модули (P1, размер S): TNTTimer, AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, - PlaceAnimation, GodRays (референс-шейдеры `shaders/core/godrays`), ProjectileTrails (на нашем `Trails`), ArmorColor. -- [ ] 5. Дальше (P1-P2): DeathEffects (распад на части, шейдер `death_blast`), Blizzard, ElytraTrails, TotemSound, Sounds, - InventorySort, StorageTracker, ConsumeESP, CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. - P3: Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. - Пропустить: GuiPresence (нужен бэкенд evo). Полный список из 29 модулей с оценкой: отчёт, раздел 1.2. -- [ ] 6. Лицензия: `ref/evo/README.md` объявляет код и ассеты evo проприетарными. Перед релизом заменить на свои модели/ - звуки или получить разрешение автора; до тех пор пути из отчёта только для локальной отладки, в репозиторий - ассеты evo не коммитить. - -### Не проверено вживую / открытые вопросы - -- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `` после `%toggle `. - Если миксин не применится, прислать лог (поля `CommandSuggestions` сверены по байткоду 26.2). -- [ ] Визуально подогнать: режим «Созвездие», новые формы JumpCircles (ориентация текстур, яркость - аддитивного бленда), кровь (лужи, яркость), Hero/orbs-меню, индикаторы войсчата. -- [ ] Войсчат: события SVC трактованы по javap (`MicrophoneMuteEvent.isDisabled()` как «заглушён или - выключен», `getEntityId()` как UUID игрока); проверить с настоящим SVC 2.6.24; значок шапки HUD-панели - сейчас wifi-глиф (подходящего микрофона в шрифте нет). -- [ ] Тип ClickGUI (идея владельца): селектор «Тип ClickGUI» в настройках + точка выбора экрана - (`ClickGuiScreenManager`, три места `new ClickGuiScreen()`), `classic` по умолчанию, второй вид позже; - бинд и звуки остаются в модуле `ClickGui`. Ждёт решения, какой второй вид делать первым. -- [ ] `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`. -- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >250 строк), по мере касания. -- [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде. - -### Заметки для следующих сессий - -- Игру запускает владелец: `cd mod && ./gradlew runClient`. Из корня репозитория `./gradlew` не существует. -- Параллельные gradle-сборки блокируют друг друга: форкам запускать через - `flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`. -- Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*` - (Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`. -- Лимиты проекта: ≤250 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении), - без длинного тире в текстах сайта и ответах. +# LoVisual Mod (26.2): план и статус + +Мод вырос из форка Combatant Client. Подробная история (фазы 1-12) лежит в git: `git log -p -- mod/TODO.md`. + +## Правила (обязательные) +- KISS / DRY / SOLID, без оверинжиниринга; публичный API менять можно, мёртвый код удалять. +- ≤250 строк на файл (новый код держим ≤200), ≤4 .java файла на папку (`checkFolderLimit` падает при нарушении). +- Коммиты на английском; владелец просит большие коммиты, не дробить; ничего не пушить без просьбы. +- Без `static Minecraft` в полях, без this-escape, без мёртвых миксинов и файлов-пустышек. +- Каждый этап компилируется (`./gradlew build`), у нового кода тесты (JUnit) на чистую логику. +- Тексты сайта и ответы без длинного тире; ответы владельцу по-русски. + +## Сделано (кратко) +- Ребрендинг, удаление Runtime/JarReplacement, разбивка файлов-гигантов (>600 строк), удаление читерских модулей, тесты. +- Перенос QoL-модулей из Delta (StreamerMode, LockSlot, AutoRespawn/Leave/Accept, ThirdPerson, UseTracker, NoServerPack, + DeathCoords, ItemScroller, TapeMouse, SoundReducer и др.), команды с префиксом `%` и автокомплит (в том числе в ванильном чате). +- Оптимизация FPS (мёртвые грузы, модуль Optimize), аудит attack-визуалов по sacura/GodWeer. +- Сессия 2026-10-01: TargetESP (Созвездие, Мечи), JumpCircles, KillEffect Blood (с depth), Hero/orbs главное меню и его + настройки, Simple Voice Chat (мягкая интеграция, модуль, HUD), тултипы (amber по умолчанию, выключаются, авто-масштаб под GUI + Scale), описания модулей и плашка описания в ClickGUI, `%tier` с подсказками ников, краши запуска (MixinExtras 0.5.5, helper-классы + вне `mixins.*`, порядок static init тем). + +## Фаза 13: порт идей из evo (отчёт: `docs/EVO_REPORT.md`) +Лицензия: код и ассеты evo проприетарные (`ref/evo/README.md`). Для релиза только свои модели/звуки или разрешение автора; +пути из отчёта нужны для локальной отладки, ассеты evo в репозиторий не коммитить. + +- [x] HoldMyItems Scripted на LuaJ: режим Dynamics/Scripts/Both; `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + + аддоны (из `config/lovisual/holdmyitems/`, горячая перезагрузка); песочница без io/os/luajava; `V:cancelEquip/cancelSwing`, + `animator` (квады модели), `particleManager`, `C:setCamRot/setCamPos`, «всегда обе руки». Код: `held/lua/{runtime,api,ext}`. + Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/`using_item`, ItemStack-поля, roll камеры, ресурс-паки. +- [x] TNTTimer: уже был в `Predictions` (плашка с таймером); добавлены «Иконка TNT» и «Полоска фитиля». +- [x] ProjectileTrails: новый модуль `visuals/effects/projectiletrails/` (`TrailStore`, `TrailRibbon`): ленты за стрелами, трезубцами, + жемчугом, снежками, яйцами, зельями, огненными шарами, ветряными зарядами, фейерверками; длительность, ширина, заливка + Solid/Glow, контур, глубина, 8 режимов цвета. Шейдерные заливки evo (Nebula, Plasma, Matrix и т.д.) не портированы. +- [x] DeathEffects: режимы KillEffect Shatter, Ascend, Explosion, Disintegrate (`hits/death/`: `DeathPhysics` чистая + 6 тестов, + `DeathBody`, `DeathMesh`, `DeathEffectHelper`; тело из 6 частей с UV скина игрока, у мобов серые кубы). Настройки в + `DeathEffectSettings`. Не сделано: скрытие ванильного трупа на время эффекта, шейдер `death_blast`, режим Lightning из evo + (у нас уже есть свой Lightning). +- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами), + `BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты. + Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`. +- [ ] Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person, звуки, шейдер holy (отчёт раздел 3.7, + порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`. +- [ ] Быстрые модули P1: AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint, PlaceAnimation, GodRays + (референс `shaders/core/godrays`), ArmorColor. +- [ ] Дальше P1-P2: Blizzard, ElytraTrails (на нашем `Trails`), TotemSound, Sounds, InventorySort, StorageTracker, ConsumeESP, + CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase. P3: Chunk Cache, Schematics, HitFix (риск античита), + Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer. Пропустить: GuiPresence. Полный список: отчёт, раздел 1.2. + +## Открытые задачи +### Проверить в игре (код готов, вживую не смотрели) +- Автокомплит `%` в ванильном чате (`%`, `%to`, Tab, `%tier `); если миксин не применится, прислать лог. +- Режимы TargetESP Созвездие и Мечи; формы JumpCircles; кровь (лужи, яркость, depth); новые режимы KillEffect (ориентация + граней частей, UV скина, яркость искр); ProjectileTrails (вид ленты, глубина); Hero/orbs-меню. +- HoldMyItems Scripted: знаки осей `M:rotate`, видимость Lua-частиц, анимация квадов на реальной модели. +- Войсчат с настоящим SVC 2.6.24 (события mute/speaking, значок шапки HUD). +- Размер плашки описания в ClickGUI и авто-масштаб тултипа при GUI Scale 2/3/4. + +### Решения и долги +- Тип ClickGUI: селектор «Тип ClickGUI» (`classic` по умолчанию), точка выбора экрана `ClickGuiScreenManager`; ждёт решения, + какой второй вид делать. +- ViewModel: свободный `Custom` swing и режимы sacura (Возвращение, Круговая); HitEffect: distortion/glow post-process; + JumpCircles: easing на появление; KillEffect: toggle «сквозь стены»; Trails: сравнение с sacura вживую. +- Фаза 8.5.3: около 150 файлов 250-400 строк и папки >4 файлов, чинить по мере касания. +- `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`. +- Приёмка серверного плагина (Velocity + `%policy`), бэкенд chat-service и addons-registry (корневой TODO.md). + +## Заметки для сессий +- Игру запускает владелец: `cd mod && ./gradlew runClient` (из корня репозитория `./gradlew` нет). +- Параллельные сборки блокируют друг друга: `flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`. +- Helper-классы для миксинов нельзя класть в пакет `dev.loki.lovisual.mixins.*` (Mixin `IllegalClassLoadError`): + им место в `mixinhandlers/`, `render/` или рядом с фичей. +- Мировые пайплайны (`WorldPipelines`) рисуют без отсечения задних граней и без записи глубины: порядок обхода вершин не важен. +- Параллельная сессия может коммитить в тот же репозиторий: перед `git commit --amend` смотреть `git log -3`. diff --git a/mod/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json b/mod/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json index 4ce8e27..4035d93 100644 --- a/mod/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json +++ b/mod/config/lovisual/hud/nondraggable/hud_element_vanilla_tooltips.json @@ -5,6 +5,7 @@ "item_info_colorize": true, "tooltip_font": "Iosevka", "tooltip_text_size": 1.0, + "tooltip_auto_scale": true, "top_enchant_ignore": [ "minecraft:binding_curse", "minecraft:vanishing_curse", diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/Predictions.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/Predictions.java index 7e833a9..5e351a3 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/Predictions.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/Predictions.java @@ -62,6 +62,8 @@ public class Predictions extends Module { private final BooleanValue glowWallsValue = bool("pred_glow_walls", SETTING_GLOW_WALLS, false); private final BooleanValue tntTimerValue = bool("pred_tnt_timer", SETTING_TNT_TIMER, true); private final RGBColorValue tntColorValue = visibleWhen(colorNoAlpha("pred_tnt_color", SETTING_TNT_COLOR, "#FF5555"), tntTimerValue::get); + private final BooleanValue tntIconValue = visibleWhen(bool("pred_tnt_icon", "tnt_icon", true), tntTimerValue::get); + private final BooleanValue tntBarValue = visibleWhen(bool("pred_tnt_bar", "tnt_fuse_bar", true), tntTimerValue::get); private final BooleanMapValue kindToggles = group("pred_kind_toggles", SETTING_PROJECTILES, new java.util.LinkedHashMap<>() {{ put("arrow", true); put("trident", true); put("fireball", true); put("wind_charge", true); put("firework", true); put("snowball", true); put("egg", true); @@ -139,7 +141,9 @@ public class Predictions extends Module { public BooleanValue getTargetHudOnAimValue() { return targetHudOnAimValue; } public RGBColorValue getTntColorValue() { return tntColorValue; } public List getTimerPlates() { return timerPlates; } + public boolean isTntBarEnabled() { return tntBarValue.get(); } public ItemStack getTntTimerIcon() { + if (!tntIconValue.get()) return ItemStack.EMPTY; if (tntTimerIcon.isEmpty()) tntTimerIcon = new ItemStack(Items.TNT); return tntTimerIcon; } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/render/PredictionsHudRenderer.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/render/PredictionsHudRenderer.java index 626592e..711ddaf 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/render/PredictionsHudRenderer.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/combat/prediction/render/PredictionsHudRenderer.java @@ -27,6 +27,7 @@ public final class PredictionsHudRenderer { private static final float PLATE_TEXT_SCALE = 0.68f; private static final float PLATE_TEXT_PADDING = 24.0f; private static final float PLATE_HEIGHT = 16.0f; + private static final float TNT_FUSE_TICKS = 80.0f; private static final float PLATE_BG_OFFSET_X = 2.0f; private static final float PLATE_BG_OFFSET_Y = 0.0f; private static final float PLATE_BG_SHRINK = 3.0f; @@ -62,9 +63,19 @@ public final class PredictionsHudRenderer { double seconds = Math.max(0.0, (fuse - tickDelta) / 20.0); drawTimerPlate(facade, renderer, textRenderer, formatTime(seconds), (float) screen.x, (float) screen.y, plateColor, facade.getTntTimerIcon(), null); + if (facade.isTntBarEnabled()) drawFuseBar(renderer, (float) screen.x, (float) screen.y, (fuse - tickDelta) / TNT_FUSE_TICKS, plateColor); } } + private static void drawFuseBar(Renderer2D renderer, float cx, float plateY, float progress, int argb) { + float w = 30.0f; + float x = cx - w * 0.5f; + float y = plateY + PLATE_HEIGHT + 2.0f; + renderer.roundedRect(x, y, w, 2.5f, 1.2f, 0xA0000000); + float fill = w * Math.max(0.0f, Math.min(1.0f, progress)); + if (fill > 1.0f) renderer.roundedRect(x, y, fill, 2.5f, 1.2f, argb | 0xFF000000); + } + public static void drawTimerPlate(Predictions facade, Renderer2D renderer, TextRenderer textRenderer, String txt, float sx, float sy, int argb, ItemStack iconStack, Identifier iconId) { diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/DeathEffectSettings.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/DeathEffectSettings.java new file mode 100644 index 0000000..44c9f5a --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/DeathEffectSettings.java @@ -0,0 +1,39 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits; + +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathEffectHelper; + +/** Настройки режимов смерти KillEffect (Shatter, Ascend, Explosion, Disintegrate); вынесены из модуля ради лимита строк. */ +abstract class DeathEffectSettings extends Module { + + protected abstract String currentMode(); + + protected final boolean isMode(String value) { + return value.equals(currentMode()); + } + + protected final boolean isDeathMode() { + return DeathEffectHelper.handles(currentMode()); + } + + protected final NumberValue deathPower = visibleWhen( + num("killEffectDeathPower", "death_power", 1.0f, 0.3f, 3.0f), this::isDeathMode); + protected final NumberValue deathDuration = visibleWhen( + num("killEffectDeathDuration", "death_duration", 2.5f, 1.0f, 6.0f), this::isDeathMode); + protected final NumberValue deathSpin = visibleWhen( + num("killEffectDeathSpin", "death_spin", 1.0f, 0.2f, 3.0f), () -> isMode("Shatter") || isMode("Ascend")); + protected final BooleanValue deathBounce = visibleWhen( + bool("killEffectDeathBounce", "death_bounce", true), () -> isMode("Shatter")); + protected final NumberValue deathSparks = visibleWhen( + num("killEffectDeathSparks", "death_sparks", 70, 10, 150), () -> isMode("Explosion")); + protected final ModeValue blastColorMode = visibleWhen( + modeSetting("killEffectBlastColor", "blast_color", "Natural", "Natural", "Client", "Custom"), + () -> isMode("Explosion")); + + protected final boolean customBlastColor() { + return isMode("Explosion") && "Custom".equals(blastColorMode.get()); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java index b7f5555..44651fa 100644 --- a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/KillEffect.java @@ -5,16 +5,16 @@ import dev.loki.lovisual.config.values.color.RGBAColorValue; import dev.loki.lovisual.config.values.mode.ModeValue; import dev.loki.lovisual.config.values.primitive.BooleanValue; import dev.loki.lovisual.config.values.primitive.NumberValue; -import dev.loki.lovisual.features.module.core.Module; import dev.loki.lovisual.features.module.core.ModuleCategory; import dev.loki.lovisual.features.module.core.ModuleInfo; import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodKillHelper; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathEffectHelper; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.effects.FallingLavaHelper; +import dev.loki.lovisual.features.theme.Theme; import dev.loki.lovisual.features.module.phase.WorldPhase; -import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor; import dev.loki.lovisual.render.engine.renderer.Renderer3D; import dev.loki.lovisual.util.wav.engine.CustomSoundEngine; import net.minecraft.client.Minecraft; -import net.minecraft.core.particles.ParticleTypes; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.player.Player; @@ -25,12 +25,13 @@ import java.util.Map; //todo Description @ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS) -public class KillEffect extends Module { +public class KillEffect extends DeathEffectSettings { private static final Identifier ORTHODOX_SOUND = Identifier.fromNamespaceAndPath("lovisual", "sounds/misc/orthodox.wav"); private final ModeValue mode = modeSetting("killEffectMode", "mode", "Orthodox", - "Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood"); + "Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood", + "Shatter", "Ascend", "Explosion", "Disintegrate"); private final NumberValue ySpeed = visibleWhen( num("killEffectYSpeed", "y_speed", 0, -10, 10), () -> isMode("Orthodox")); private final BooleanValue playSound = visibleWhen( @@ -66,13 +67,15 @@ public class KillEffect extends Module { private final KillLightningHelper lightningHelper = new KillLightningHelper(); private final KillBeamHelper beamHelper = new KillBeamHelper(); private final BloodKillHelper bloodHelper = new BloodKillHelper(); + private final DeathEffectHelper deathHelper = new DeathEffectHelper(); - private boolean isMode(String value) { - return value.equals(mode.get()); + @Override + protected String currentMode() { + return mode.get(); } private boolean usesColorSetting() { - return isMode("Orthodox") || isMode("Lightning") || usesBeamSettings(); + return isMode("Orthodox") || isMode("Lightning") || usesBeamSettings() || customBlastColor(); } private boolean usesBeamSettings() { @@ -126,7 +129,7 @@ public class KillEffect extends Module { CustomSoundEngine.get().play(ORTHODOX_SOUND, soundVolume.get(), 1.0, false, true); } } else if ("FallingLava".equals(current)) { - spawnFallingLava(entity); + FallingLavaHelper.spawn(entity); } else if ("EmbersArc".equals(current)) { emberHelper.spawn(pos); } else if ("Lightning".equals(current)) { @@ -135,6 +138,9 @@ public class KillEffect extends Module { beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.PORTAL); } else if ("Tornado".equals(current)) { beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.TORNADO); + } else if (DeathEffectHelper.handles(current)) { + deathHelper.spawn(entity, DeathEffectHelper.styleOf(current), deathPower.get(), deathDuration.get(), + deathSpin.get(), deathBounce.get(), deathSparks.get(), blastColor()); } else if ("Blood".equals(current)) { bloodHelper.spawn(new Vec3(pos.x, pos.y + entity.getBbHeight() * 0.6, pos.z), Math.clamp(entity.getBbWidth(), 0.3f, 1.6f), Math.clamp(entity.getBbHeight(), 0.3f, 2.6f), @@ -151,6 +157,7 @@ public class KillEffect extends Module { lightningHelper.clear(); beamHelper.clear(); bloodHelper.clear(); + deathHelper.clear(); } @Override @@ -167,28 +174,16 @@ public class KillEffect extends Module { } else if ("EmbersArc".equals(current)) { emberHelper.render(renderer, tickDelta); } + deathHelper.render(renderer, tickDelta); beamHelper.render(renderer, mc, beamIntensity.get(), beamSize.get()); bloodHelper.render(renderer, mc.gameRenderer.mainCamera().position(), tickDelta, bloodTime.get()); } - private void spawnFallingLava(LivingEntity entity) { - Minecraft mc = Minecraft.getInstance(); - if (mc.level == null) { - return; - } - int height = (int) (entity.getBbHeight() * 10); - int width = (int) (entity.getBbWidth() * 10); - for (int i = 0; i < height; i++) { - for (int j = 0; j < width; j++) { - for (int k = 0; k < width; k++) { - ((ClientLevelAccessor) mc.level).invokeAddParticle( - ParticleTypes.FALLING_LAVA, false, true, - entity.getX() + j * 0.1, - entity.getY() + i * 0.1, - entity.getZ() + k * 0.1, - 0, 0, 0); - } - } - } + private int blastColor() { + return switch (blastColorMode.get()) { + case "Client" -> Theme.theme().accent() | 0xFF000000; + case "Custom" -> color.getArgb(); + default -> 0xFFFFA030; + }; } } diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathBody.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathBody.java new file mode 100644 index 0000000..839ac5c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathBody.java @@ -0,0 +1,54 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death; + +import net.minecraft.world.entity.LivingEntity; + +import java.util.ArrayList; +import java.util.List; + +/** Разбиение тела на 6 частей (голова, торс, две руки, две ноги) с UV-прямоугольниками стандартного скина 64x64. */ +public final class DeathBody { + + /** Размеры в пикселях (w,h,d), начало UV (u,v) и смещение центра в блоках: kind -> {w,h,d,u,v,cx,cy}. */ + private static final float[][] LAYOUT = { + {8, 8, 8, 0, 0, 0.0f, 1.75f}, + {8, 12, 4, 16, 16, 0.0f, 1.125f}, + {4, 12, 4, 40, 16, -0.375f, 1.125f}, + {4, 12, 4, 32, 48, 0.375f, 1.125f}, + {4, 12, 4, 0, 16, -0.125f, 0.375f}, + {4, 12, 4, 16, 48, 0.125f, 0.375f}, + }; + + private DeathBody() { + } + + public static float[] layout(int kind) { + return LAYOUT[kind]; + } + + /** + * Части в мировых координатах: поза «стоя», повёрнутая по yaw тела, масштаб по росту сущности. + * Центр тела считается на высоте груди. + */ + public static List build(double px, double py, double pz, float bodyYawDeg, float height) { + float scale = Math.max(0.3f, height / 1.8f); + double yaw = Math.toRadians(-bodyYawDeg); + float cos = (float) Math.cos(yaw); + float sin = (float) Math.sin(yaw); + List parts = new ArrayList<>(LAYOUT.length); + for (int i = 0; i < LAYOUT.length; i++) { + float[] l = LAYOUT[i]; + float lx = l[5] * scale; + float wx = lx * cos; + float wz = -lx * sin; + DeathPhysics.Part p = new DeathPhysics.Part(i, (float) px + wx, (float) py + l[6] * scale, (float) pz + wz, + l[0] / 32.0f * scale, l[1] / 32.0f * scale, l[2] / 32.0f * scale); + p.ry = (float) yaw; + parts.add(p); + } + return parts; + } + + public static float height(LivingEntity e) { + return e.getBbHeight(); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathEffectHelper.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathEffectHelper.java new file mode 100644 index 0000000..c7be44f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathEffectHelper.java @@ -0,0 +1,178 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Part; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Style; +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.render.engine.texture.TextureStorage; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; +import dev.loki.lovisual.util.player.health.PlayerSkinResolver; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.LivingEntity; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Random; + +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices; +import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity; + +/** Режимы смерти из evo: тело рассыпается на части (Shatter/Explosion), взлетает (Ascend) или тает (Disintegrate). */ +public final class DeathEffectHelper { + + private static final int FALLBACK_TINT = 0xFF9A9A9A; + private static final float SPARK_LIFE = 0.9f; + private static final float FLASH_LIFE = 0.45f; + + private static final class Spark { + float x, y, z, vx, vy, vz; + } + + private static final class Corpse { + final List parts; + final List sparks = new ArrayList<>(); + final Style style; + final long start; + final float duration; + final float groundY; + final boolean bounce; + final Identifier skin; + final int sparkColor; + final float[] center; + long last; + + Corpse(List parts, Style style, long start, float duration, float groundY, boolean bounce, + Identifier skin, int sparkColor, float[] center) { + this.parts = parts; + this.style = style; + this.start = start; + this.last = start; + this.duration = duration; + this.groundY = groundY; + this.bounce = bounce; + this.skin = skin; + this.sparkColor = sparkColor; + this.center = center; + } + } + + private final List corpses = new ArrayList<>(); + private final Random random = new Random(); + + public static Style styleOf(String mode) { + return switch (mode) { + case "Ascend" -> Style.ASCEND; + case "Explosion" -> Style.EXPLOSION; + case "Disintegrate" -> Style.DISINTEGRATE; + default -> Style.SHATTER; + }; + } + + public static boolean handles(String mode) { + return "Shatter".equals(mode) || "Ascend".equals(mode) || "Explosion".equals(mode) || "Disintegrate".equals(mode); + } + + public void clear() { + corpses.clear(); + } + + public void spawn(LivingEntity e, Style style, float power, float duration, float spin, boolean bounce, + int sparks, int sparkColor) { + float height = DeathBody.height(e); + List parts = DeathBody.build(e.getX(), e.getY(), e.getZ(), e.yBodyRot, height); + float cx = (float) e.getX(), cy = (float) e.getY() + height * 0.6f, cz = (float) e.getZ(); + for (Part p : parts) DeathPhysics.launch(p, style, power, spin, duration, cx, cy, cz, random); + Identifier skin = e instanceof AbstractClientPlayer player ? PlayerSkinResolver.resolvePlayerSkin(player) : null; + Corpse c = new Corpse(parts, style, GameClock.millis(), duration, (float) e.getY(), bounce, skin, sparkColor, + new float[]{cx, cy, cz}); + if (style == Style.EXPLOSION) { + for (int i = 0; i < sparks; i++) c.sparks.add(spark(cx, cy, cz, power)); + } + corpses.add(c); + } + + private Spark spark(float cx, float cy, float cz, float power) { + Spark s = new Spark(); + s.x = cx; + s.y = cy; + s.z = cz; + float theta = random.nextFloat() * 6.2831855f; + float up = random.nextFloat() * 2.0f - 0.6f; + float speed = (2.0f + random.nextFloat() * 5.0f) * power; + float horiz = (float) Math.sqrt(Math.max(0.0, 1.0 - up * up * 0.25)); + s.vx = (float) Math.cos(theta) * horiz * speed; + s.vz = (float) Math.sin(theta) * horiz * speed; + s.vy = up * speed; + return s; + } + + public void render(Renderer3D renderer, float tickDelta) { + if (corpses.isEmpty() || renderer == null) return; + long now = GameClock.millis(); + for (Iterator it = corpses.iterator(); it.hasNext(); ) { + Corpse c = it.next(); + float t = (now - c.start) / 1000.0f; + if (t > c.duration + 0.2f) { + it.remove(); + continue; + } + advance(c, now, t); + drawParts(renderer, c, t); + if (c.style == Style.EXPLOSION) drawSparks(renderer, c, t); + } + } + + private void advance(Corpse c, long now, float t) { + float dt = Math.min(0.1f, (now - c.last) / 1000.0f); + c.last = now; + int steps = Math.max(1, (int) Math.ceil(dt / 0.02f)); + float h = dt / steps; + for (int i = 0; i < steps; i++) { + for (Part p : c.parts) DeathPhysics.step(p, c.style, h, t, c.groundY, c.bounce); + } + for (Spark s : c.sparks) { + s.vy -= 9.0f * dt; + s.x += s.vx * dt; + s.y += s.vy * dt; + s.z += s.vz * dt; + } + } + + private void drawParts(Renderer3D renderer, Corpse c, float t) { + boolean textured = c.skin != null; + MeshBuilder mesh = textured + ? renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_DEPTH, c.skin, Renderer3D.DepthMode.MAIN) + : renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN); + if (mesh == null) return; + for (Part p : c.parts) { + float alpha = DeathPhysics.alpha(p, c.style, t, c.duration); + if (alpha > 0.01f) DeathMesh.draw(mesh, p, DeathBody.layout(p.kind), textured ? 0xFFFFFF : FALLBACK_TINT & 0xFFFFFF, alpha, textured); + } + } + + private void drawSparks(Renderer3D renderer, Corpse c, float t) { + MeshBuilder glow = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.BLOOM, + Renderer3D.DepthMode.NONE); + if (glow == null) return; + Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation); + Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation); + if (t < FLASH_LIFE) { + float k = t / FLASH_LIFE; + float size = 0.6f + k * 3.2f; + int col = applyOpacity(c.sparkColor, (1.0f - k) * (1.0f - k)); + addBillboardVertices(glow, c.center[0], c.center[1], c.center[2], new Vector3f(right).mul(size), + new Vector3f(up).mul(size), col, col, col, col); + } + float fade = Math.max(0.0f, 1.0f - t / SPARK_LIFE); + if (fade <= 0.0f) return; + int col = applyOpacity(c.sparkColor, fade); + Vector3f r = new Vector3f(right).mul(0.07f); + Vector3f u = new Vector3f(up).mul(0.07f); + for (Spark s : c.sparks) addBillboardVertices(glow, s.x, s.y, s.z, r, u, col, col, col, col); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathMesh.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathMesh.java new file mode 100644 index 0000000..902c82f --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathMesh.java @@ -0,0 +1,94 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death; + +import dev.loki.lovisual.render.engine.color.RenderColor; +import dev.loki.lovisual.render.engine.uniform.MeshBuilder; + +/** + * Рисует часть тела как куб с UV скина. Мировые пайплайны рисуют без отсечения задних граней, + * поэтому порядок обхода вершин не важен. + */ +final class DeathMesh { + + private static final float TEX = 64.0f; + /** Яркость граней: +x, -x, +y, -y, +z, -z. */ + private static final float[] SHADE = {0.8f, 0.8f, 1.0f, 0.55f, 0.9f, 0.7f}; + + private DeathMesh() { + } + + static void draw(MeshBuilder mesh, DeathPhysics.Part p, float[] layout, int rgb, float alpha, boolean textured) { + float w = p.hw, h = p.hh, d = p.hd; + float u = layout[3], v = layout[4]; + float pw = layout[0], ph = layout[1], pd = layout[2]; + float[][] c = corners(p, w, h, d); + // порядок углов: 0:-x-y-z 1:+x-y-z 2:+x+y-z 3:-x+y-z 4:-x-y+z 5:+x-y+z 6:+x+y+z 7:-x+y+z + face(mesh, c, 4, 5, 6, 7, u + pd, v + pd, pw, ph, rgb, alpha, SHADE[4], textured); // +z (перед) + face(mesh, c, 1, 0, 3, 2, u + pd + pw + pd, v + pd, pw, ph, rgb, alpha, SHADE[5], textured); // -z (спина) + face(mesh, c, 0, 4, 7, 3, u, v + pd, pd, ph, rgb, alpha, SHADE[1], textured); // -x (правая сторона) + face(mesh, c, 5, 1, 2, 6, u + pd + pw, v + pd, pd, ph, rgb, alpha, SHADE[0], textured); // +x (левая сторона) + face(mesh, c, 7, 6, 2, 3, u + pd, v, pw, pd, rgb, alpha, SHADE[2], textured); // +y (верх) + face(mesh, c, 0, 1, 5, 4, u + pd + pw, v, pw, pd, rgb, alpha, SHADE[3], textured); // -y (низ) + } + + /** Углы куба после поворота Эйлера (Y, затем X, затем Z) и переноса в центр части. */ + private static float[][] corners(DeathPhysics.Part p, float w, float h, float d) { + float[][] out = new float[8][3]; + for (int i = 0; i < 8; i++) { + float x = (i & 1) == 0 ? -w : w; + float y = (i & 2) == 0 ? -h : h; + float z = (i & 4) == 0 ? -d : d; + // индексация: бит0 = x, бит1 = y, бит2 = z; пересобираем в порядок из draw() + out[i] = rotate(p, x, y, z); + } + return remap(out); + } + + private static float[][] remap(float[][] bits) { + // bits[i]: i = x | y<<1 | z<<2. Нужен порядок 0:(-,-,-) 1:(+,-,-) 2:(+,+,-) 3:(-,+,-) 4:(-,-,+) 5:(+,-,+) 6:(+,+,+) 7:(-,+,+) + return new float[][]{bits[0], bits[1], bits[3], bits[2], bits[4], bits[5], bits[7], bits[6]}; + } + + private static float[] rotate(DeathPhysics.Part p, float x, float y, float z) { + float cy = (float) Math.cos(p.ry), sy = (float) Math.sin(p.ry); + float x1 = x * cy + z * sy, z1 = -x * sy + z * cy; + float cx = (float) Math.cos(p.rx), sx = (float) Math.sin(p.rx); + float y2 = y * cx - z1 * sx, z2 = y * sx + z1 * cx; + float cz = (float) Math.cos(p.rz), sz = (float) Math.sin(p.rz); + float x3 = x1 * cz - y2 * sz, y3 = x1 * sz + y2 * cz; + return new float[]{p.x + x3, p.y + y3, p.z + z2}; + } + + private static void face(MeshBuilder m, float[][] c, int a, int b, int cc, int dd, float u, float v, + float uw, float vh, int rgb, float alpha, float shade, boolean textured) { + int argb = argb(rgb, alpha, shade); + float u0 = u / TEX, u1 = (u + uw) / TEX, v0 = v / TEX, v1 = (v + vh) / TEX; + // a: низ-слева, b: низ-справа, cc: верх-справа, dd: верх-слева (для боковых граней) + quad(m, c[a], c[b], c[cc], c[dd], u0, v1, u1, v1, u1, v0, u0, v0, argb, textured); + } + + private static void quad(MeshBuilder m, float[] p1, float[] p2, float[] p3, float[] p4, + float ua, float va, float ub, float vb, float uc, float vc, float ud, float vd, + int argb, boolean textured) { + m.ensureQuadCapacity(); + RenderColor col = new RenderColor(argb); + int i1 = vertex(m, p1, ua, va, col, textured); + int i2 = vertex(m, p2, ub, vb, col, textured); + int i3 = vertex(m, p3, uc, vc, col, textured); + int i4 = vertex(m, p4, ud, vd, col, textured); + m.quad(i1, i2, i3, i4); + } + + private static int vertex(MeshBuilder m, float[] p, float u, float v, RenderColor col, boolean textured) { + var b = m.vec3(p[0], p[1], p[2]); + if (textured) b = b.vec2(u, v); + return b.color(col).next(); + } + + private static int argb(int rgb, float alpha, float shade) { + int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f))); + int r = Math.round(((rgb >> 16) & 0xFF) * shade); + int g = Math.round(((rgb >> 8) & 0xFF) * shade); + int bl = Math.round((rgb & 0xFF) * shade); + return (a << 24) | (r << 16) | (g << 8) | bl; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysics.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysics.java new file mode 100644 index 0000000..94006cb --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysics.java @@ -0,0 +1,137 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death; + +import java.util.Random; + +/** Чистая физика частей тела для режимов смерти (Shatter, Ascend, Explosion, Disintegrate); без Minecraft. */ +public final class DeathPhysics { + + public enum Style { SHATTER, ASCEND, EXPLOSION, DISINTEGRATE } + + /** Часть тела: центр, скорость, углы Эйлера (рад), угловая скорость, половинные размеры, задержка. */ + public static final class Part { + public float x, y, z, vx, vy, vz; + public float rx, ry, rz, wx, wy, wz; + public final float hw, hh, hd; + public final int kind; + public float delay; + public float phase; + boolean resting; + + public Part(int kind, float x, float y, float z, float hw, float hh, float hd) { + this.kind = kind; + this.x = x; + this.y = y; + this.z = z; + this.hw = hw; + this.hh = hh; + this.hd = hd; + } + + float radius() { + return Math.max(hw, Math.max(hh, hd)); + } + } + + private static final float GRAVITY = 16.0f; + + private DeathPhysics() { + } + + /** Начальный импульс части относительно центра тела (cx, cy, cz). */ + public static void launch(Part p, Style style, float power, float spin, float duration, + float cx, float cy, float cz, Random r) { + float dx = p.x - cx + (r.nextFloat() - 0.5f) * 0.2f; + float dy = p.y - cy; + float dz = p.z - cz + (r.nextFloat() - 0.5f) * 0.2f; + float len = Math.max(0.05f, (float) Math.sqrt(dx * dx + dy * dy + dz * dz)); + p.phase = r.nextFloat() * 6.2831855f; + switch (style) { + case SHATTER -> { + float k = (1.5f + r.nextFloat() * 1.5f) * power; + set(p, dx / len * k, 2.5f * power + dy / len * k, dz / len * k, spin * 7.0f, r); + } + case EXPLOSION -> { + float k = (5.0f + r.nextFloat() * 3.0f) * power; + set(p, dx / len * k, 3.0f * power + dy / len * k * 0.6f, dz / len * k, spin * 12.0f, r); + } + case ASCEND -> set(p, 0.0f, (0.5f + r.nextFloat() * 0.4f) * power, 0.0f, spin * 1.2f, r); + default -> { + set(p, 0.0f, 0.0f, 0.0f, spin * 0.5f, r); + p.delay = Math.max(0.0f, (2.0f - p.y) / 2.0f) * duration * 0.45f; + } + } + } + + private static void set(Part p, float vx, float vy, float vz, float spin, Random r) { + p.vx = vx; + p.vy = vy; + p.vz = vz; + p.wx = (r.nextFloat() - 0.5f) * 2.0f * spin; + p.wy = (r.nextFloat() - 0.5f) * 2.0f * spin; + p.wz = (r.nextFloat() - 0.5f) * 2.0f * spin; + } + + /** Шаг физики; t: секунды от начала эффекта, groundY: уровень пола, bounce: отскок от земли. */ + public static void step(Part p, Style style, float dt, float t, float groundY, boolean bounce) { + if (p.resting) return; + switch (style) { + case ASCEND -> { + p.x += (float) Math.sin(t * 2.0f + p.phase) * 0.25f * dt; + p.y += p.vy * dt; + spinStep(p, dt); + } + case DISINTEGRATE -> { + if (t > p.delay) p.y += 0.6f * dt; + spinStep(p, dt); + } + default -> ballistic(p, dt, groundY, bounce); + } + } + + private static void ballistic(Part p, float dt, float groundY, boolean bounce) { + p.vy -= GRAVITY * dt; + float drag = (float) Math.pow(0.985, dt * 60.0); + p.vx *= drag; + p.vz *= drag; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.z += p.vz * dt; + spinStep(p, dt); + float floor = groundY + p.radius(); + if (p.y >= floor) return; + p.y = floor; + if (bounce && Math.abs(p.vy) > 1.0f) { + p.vy = -p.vy * 0.45f; + p.vx *= 0.8f; + p.vz *= 0.8f; + p.wx *= 0.7f; + p.wy *= 0.7f; + p.wz *= 0.7f; + } else { + p.vx = p.vy = p.vz = 0.0f; + p.wx = p.wy = p.wz = 0.0f; + p.resting = true; + } + } + + private static void spinStep(Part p, float dt) { + p.rx += p.wx * dt; + p.ry += p.wy * dt; + p.rz += p.wz * dt; + } + + /** Непрозрачность части в момент t (0..1). */ + public static float alpha(Part p, Style style, float t, float duration) { + float a = switch (style) { + case SHATTER -> 1.0f - ramp(t, duration * 0.6f, duration * 0.4f); + case EXPLOSION -> 1.0f - ramp(t, duration * 0.4f, duration * 0.6f); + case ASCEND -> 1.0f - ramp(t, duration * 0.3f, duration * 0.7f); + default -> 1.0f - ramp(t, p.delay, duration * 0.35f); + }; + return Math.max(0.0f, Math.min(1.0f, a)); + } + + private static float ramp(float t, float start, float length) { + return Math.max(0.0f, Math.min(1.0f, (t - start) / Math.max(0.001f, length))); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/effects/FallingLavaHelper.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/effects/FallingLavaHelper.java new file mode 100644 index 0000000..3a6b5ef --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/effects/FallingLavaHelper.java @@ -0,0 +1,28 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.effects; + +import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.world.entity.LivingEntity; + +/** Лавовые капли по объёму тела (режим FallingLava у KillEffect). */ +public final class FallingLavaHelper { + + private FallingLavaHelper() { + } + + public static void spawn(LivingEntity entity) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) return; + int height = (int) (entity.getBbHeight() * 10); + int width = (int) (entity.getBbWidth() * 10); + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + for (int k = 0; k < width; k++) { + ((ClientLevelAccessor) mc.level).invokeAddParticle(ParticleTypes.FALLING_LAVA, false, true, + entity.getX() + j * 0.1, entity.getY() + i * 0.1, entity.getZ() + k * 0.1, 0, 0, 0); + } + } + } + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java new file mode 100644 index 0000000..98992f4 --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/ProjectileTrails.java @@ -0,0 +1,95 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; + +import dev.loki.lovisual.config.values.color.RGBAColorValue; +import dev.loki.lovisual.config.values.mode.ModeValue; +import dev.loki.lovisual.config.values.primitive.BooleanMapValue; +import dev.loki.lovisual.config.values.primitive.BooleanValue; +import dev.loki.lovisual.config.values.primitive.NumberValue; +import dev.loki.lovisual.features.module.core.Module; +import dev.loki.lovisual.features.module.core.ModuleCategory; +import dev.loki.lovisual.features.module.core.ModuleInfo; +import dev.loki.lovisual.features.module.phase.WorldPhase; +import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors; +import dev.loki.lovisual.render.engine.renderer.Renderer3D; +import dev.loki.lovisual.util.time.GameClock; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */ +@ModuleInfo( + id = "projectiletrails", + displayName = "ProjectileTrails", + category = ModuleCategory.VISUALS, + description = "Draws fading trails behind arrows, pearls and other projectiles" +) +public class ProjectileTrails extends Module { + + private final TrailStore store = new TrailStore(); + private final NumberValue duration = num("projectileTrailsDuration", "duration", 500, 100, 2000); + private final NumberValue width = num("projectileTrailsWidth", "width", 0.15f, 0.05f, 1.5f); + private final BooleanValue fill = bool("projectileTrailsFill", "fill", true); + private final ModeValue fillType = visibleWhen(modeSetting("projectileTrailsFillType", "fill_type", "Solid", "Solid", "Glow"), fill::get); + private final BooleanValue lines = bool("projectileTrailsLines", "lines", true); + private final NumberValue lineWidth = visibleWhen(num("projectileTrailsLineWidth", "line_width", 2.0f, 1.0f, 5.0f), lines::get); + private final BooleanValue depthTest = bool("projectileTrailsDepth", "depth_test", true); + private final ModeValue colorMode = modeSetting("projectileTrailsColorMode", "color_mode", "Theme", + "Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"); + private final RGBAColorValue color = color("projectileTrailsColor", "color", "#CCFF55FF"); + private final RGBAColorValue color2 = color("projectileTrailsColor2", "color2", "#CC55FFFF"); + private final NumberValue colorSpeed = num("projectileTrailsColorSpeed", "color_speed", 18, 2, 54); + private final BooleanMapValue kinds = group("projectileTrailsKinds", "projectiles", Map.of( + "arrow", true, "trident", true, "pearl", true, "snowball", true, "egg", true, + "potion", true, "fireball", true, "wind_charge", true, "firework", false)); + + @Override + public WorldPhase getWorldPhase() { + return WorldPhase.END_MAIN; + } + + @Override + public void onDisable() { + store.clear(); + } + + @Override + public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) { + Minecraft mc = Minecraft.getInstance(); + if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return; + long now = GameClock.millis(); + long life = duration.get(); + for (Entity e : mc.level.entitiesForRendering()) { + String kind = TrailStore.kindOf(e); + if (kind != null && kinds.get(kind) && e.isAlive()) store.sample(e, tickDelta, now, life); + } + store.prune(now, life, id -> mc.level.getEntity(id) != null); + if (store.tracks().isEmpty()) return; + int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f); + AnimatedRenderColors.Mode mode = parse(colorMode.get()); + int primary = color.getArgb(); + int secondary = color2.getArgb(); + int speed = colorSpeed.get(); + List tracks = new ArrayList<>(store.tracks().values()); + Vec3 cam = mc.gameRenderer.mainCamera().position(); + TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), "Glow".equals(fillType.get()), + lines.get(), lineWidth.get(), depthTest.get(), cam, + (i, n) -> AnimatedRenderColors.resolve(mode, speed, phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true)); + } + + private static AnimatedRenderColors.Mode parse(String mode) { + return switch (mode) { + case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW; + case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW; + case "Sky" -> AnimatedRenderColors.Mode.SKY; + case "Fade" -> AnimatedRenderColors.Mode.FADE; + case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR; + case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS; + case "Theme" -> AnimatedRenderColors.Mode.THEME; + default -> AnimatedRenderColors.Mode.STATIC; + }; + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java new file mode 100644 index 0000000..833992c --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailRibbon.java @@ -0,0 +1,99 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; + +import dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails.TrailStore.Point; +import dev.loki.lovisual.render.engine.RenderState; +import dev.loki.lovisual.render.engine.color.RenderColor; +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.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntBinaryOperator; + +/** Строит ленту, повёрнутую к камере, с затуханием и сужением к хвосту, плюс контурные линии по краям. */ +final class TrailRibbon { + + private TrailRibbon() { + } + + /** + * @param color цвет точки: (индекс от хвоста, число точек) -> ARGB + */ + static void render(Renderer3D renderer, List tracks, long now, long lifeMs, float width, + boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam, + IntBinaryOperator color) { + MeshBuilder fillMesh = null; + if (fill) { + var pipeline = glow + ? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE) + : (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED); + fillMesh = renderer.batch(pipeline, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN); + } + MeshBuilder lineMesh = null; + if (lines) { + float prev = RenderState.lineWidth; + try { + RenderState.lineWidth = Math.max(0.5f, lineWidth); + lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE + : LoVisualRenderPipelines.WORLD_COLORED_LINES, depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN); + } finally { + RenderState.lineWidth = prev; + } + } + if (fillMesh == null && lineMesh == null) return; + for (TrailStore.Track track : tracks) { + List pts = new ArrayList<>(track.points); + if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, lineMesh, cam, color); + } + } + + private static void draw(List pts, long now, long lifeMs, float width, MeshBuilder fill, MeshBuilder line, + Vec3 cam, IntBinaryOperator color) { + int n = pts.size(); + double[][] left = new double[n][3]; + double[][] right = new double[n][3]; + int[] argb = new int[n]; + for (int i = 0; i < n; i++) { + Point p = pts.get(i); + Point a = pts.get(Math.max(0, i - 1)); + Point b = pts.get(Math.min(n - 1, i + 1)); + double dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z(); + double vx = cam.x - p.x(), vy = cam.y - p.y(), vz = cam.z - p.z(); + double sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx; + double len = Math.sqrt(sx * sx + sy * sy + sz * sz); + if (len < 1e-6) len = 1.0; + float life = 1.0f - Math.min(1.0f, (now - p.ms()) / (float) lifeMs); + double half = width * 0.5 * (0.25 + 0.75 * life) / len; + left[i] = new double[]{p.x() + sx * half, p.y() + sy * half, p.z() + sz * half}; + right[i] = new double[]{p.x() - sx * half, p.y() - sy * half, p.z() - sz * half}; + int base = color.applyAsInt(i, n); + int alpha = Math.round(((base >>> 24) & 0xFF) * life * life); + argb[i] = (alpha << 24) | (base & 0x00FFFFFF); + } + for (int i = 1; i < n; i++) { + if (fill != null) quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]); + if (line != null) { + segment(line, left[i - 1], left[i], argb[i - 1], argb[i]); + segment(line, right[i - 1], right[i], argb[i - 1], argb[i]); + } + } + } + + private static void quad(MeshBuilder m, double[] l0, double[] r0, double[] r1, double[] l1, int c0, int c1) { + m.ensureQuadCapacity(); + int a = m.vec3(l0[0], l0[1], l0[2]).color(new RenderColor(c0)).next(); + int b = m.vec3(r0[0], r0[1], r0[2]).color(new RenderColor(c0)).next(); + int c = m.vec3(r1[0], r1[1], r1[2]).color(new RenderColor(c1)).next(); + int d = m.vec3(l1[0], l1[1], l1[2]).color(new RenderColor(c1)).next(); + m.quad(a, b, c, d); + } + + private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) { + m.ensureLineCapacity(); + int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next(); + int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next(); + m.line(a, b); + } +} diff --git a/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java new file mode 100644 index 0000000..c84f88b --- /dev/null +++ b/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/effects/projectiletrails/TrailStore.java @@ -0,0 +1,83 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayDeque; +import java.util.Iterator; +import java.util.Map; +import java.util.HashMap; +import java.util.function.Predicate; + +/** История позиций летящих снарядов: точки с метками времени, устаревшие и пропавшие следы вычищаются. */ +final class TrailStore { + + /** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */ + private static final double MIN_STEP = 0.04; + + record Point(double x, double y, double z, long ms) { + } + + static final class Track { + final ArrayDeque points = new ArrayDeque<>(); + long lastSeen; + } + + private final Map tracks = new HashMap<>(); + + Map tracks() { + return tracks; + } + + void clear() { + tracks.clear(); + } + + /** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */ + static String kindOf(Entity entity) { + if (!(entity instanceof Projectile)) return null; + String path = BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath(); + if (path.contains("arrow")) return "arrow"; + if (path.contains("trident")) return "trident"; + if (path.contains("pearl")) return "pearl"; + if (path.contains("snowball")) return "snowball"; + if (path.contains("egg")) return "egg"; + if (path.contains("potion")) return "potion"; + if (path.contains("wind_charge")) return "wind_charge"; + if (path.contains("fireball") || path.contains("fire_charge")) return "fireball"; + if (path.contains("firework")) return "firework"; + return null; + } + + /** Добавляет текущую позицию снаряда (интерполированную) и отбрасывает старые точки. */ + void sample(Entity entity, float partialTicks, long now, long lifeMs) { + Track track = tracks.computeIfAbsent(entity.getId(), id -> new Track()); + track.lastSeen = now; + Vec3 p = entity.getPosition(partialTicks); + Point last = track.points.peekLast(); + if (last == null || last.ms() != now && dist(last, p) >= MIN_STEP) { + track.points.addLast(new Point(p.x, p.y, p.z, now)); + } + trim(track, now, lifeMs); + } + + /** Чистит следы: пропавшие из мира снаряды дотлевают, потом удаляются. */ + void prune(long now, long lifeMs, Predicate alive) { + for (Iterator> it = tracks.entrySet().iterator(); it.hasNext(); ) { + Map.Entry e = it.next(); + if (!alive.test(e.getKey())) trim(e.getValue(), now, lifeMs); + if (e.getValue().points.isEmpty() || now - e.getValue().lastSeen > lifeMs * 2) it.remove(); + } + } + + private static void trim(Track track, long now, long lifeMs) { + while (!track.points.isEmpty() && now - track.points.peekFirst().ms() > lifeMs) track.points.pollFirst(); + } + + private static double dist(Point a, Vec3 b) { + double dx = a.x() - b.x, dy = a.y() - b.y, dz = a.z() - b.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } +} diff --git a/mod/src/main/resources/assets/lovisual/lang/en_us.json b/mod/src/main/resources/assets/lovisual/lang/en_us.json index a234cbc..30dea30 100644 --- a/mod/src/main/resources/assets/lovisual/lang/en_us.json +++ b/mod/src/main/resources/assets/lovisual/lang/en_us.json @@ -1042,6 +1042,8 @@ "setting.predictions.aim_trajectory": "Aim Trajectory", "setting.predictions.aim_indicator": "Aim Indicator", "setting.predictions.tnt_timer": "TNT Timer", + "setting.predictions.tnt_icon": "TNT icon", + "setting.predictions.tnt_fuse_bar": "TNT fuse bar", "setting.predictions.tnt_color": "TNT Color", "setting.predictions.show_plate": "Show Impact Plate", "setting.reimaginedvisual.night_mode.option.NIGHT": "Dark night", @@ -3632,5 +3634,50 @@ "module.rain.description": "Customizes rain visuals and sounds.", "module.spatialgui.description": "Renders the interface as a floating panel in 3D space.", "module.trinket.description": "Attaches decorative trinkets to your character.", - "module.wet_world.description": "Makes surfaces look wet during rain." + "module.wet_world.description": "Makes surfaces look wet during rain.", + "module.projectiletrails.description": "Draws fading trails behind arrows, pearls and other projectiles.", + "setting.projectiletrails.duration": "Duration (ms)", + "setting.projectiletrails.width": "Width", + "setting.projectiletrails.fill": "Fill", + "setting.projectiletrails.fill_type": "Fill type", + "setting.projectiletrails.fill_type.option.Solid": "Solid", + "setting.projectiletrails.fill_type.option.Glow": "Glow", + "setting.projectiletrails.lines": "Outline", + "setting.projectiletrails.line_width": "Outline width", + "setting.projectiletrails.depth_test": "Depth test", + "setting.projectiletrails.color_mode": "Color mode", + "setting.projectiletrails.color": "Color", + "setting.projectiletrails.color2": "Second color", + "setting.projectiletrails.color_speed": "Color speed", + "setting.projectiletrails.projectiles": "Projectiles", + "setting.projectiletrails.color_mode.option.Static": "Static", + "setting.projectiletrails.color_mode.option.Rainbow": "Rainbow", + "setting.projectiletrails.color_mode.option.LightRainbow": "Light Rainbow", + "setting.projectiletrails.color_mode.option.Sky": "Sky", + "setting.projectiletrails.color_mode.option.Fade": "Fade", + "setting.projectiletrails.color_mode.option.DoubleColor": "Double Color", + "setting.projectiletrails.color_mode.option.Analogous": "Analogous", + "setting.projectiletrails.color_mode.option.Theme": "Theme", + "setting.projectiletrails.projectiles.option.arrow": "Arrows", + "setting.projectiletrails.projectiles.option.trident": "Tridents", + "setting.projectiletrails.projectiles.option.pearl": "Ender pearls", + "setting.projectiletrails.projectiles.option.snowball": "Snowballs", + "setting.projectiletrails.projectiles.option.egg": "Eggs", + "setting.projectiletrails.projectiles.option.potion": "Potions", + "setting.projectiletrails.projectiles.option.fireball": "Fireballs", + "setting.projectiletrails.projectiles.option.wind_charge": "Wind charges", + "setting.projectiletrails.projectiles.option.firework": "Fireworks", + "setting.killeffect.mode.option.Shatter": "Shatter", + "setting.killeffect.mode.option.Ascend": "Ascend", + "setting.killeffect.mode.option.Explosion": "Explosion", + "setting.killeffect.mode.option.Disintegrate": "Disintegrate", + "setting.killeffect.death_power": "Power", + "setting.killeffect.death_duration": "Duration (s)", + "setting.killeffect.death_spin": "Spin", + "setting.killeffect.death_bounce": "Bounce off the ground", + "setting.killeffect.death_sparks": "Sparks", + "setting.killeffect.blast_color": "Blast color", + "setting.killeffect.blast_color.option.Natural": "Natural", + "setting.killeffect.blast_color.option.Client": "Client theme", + "setting.killeffect.blast_color.option.Custom": "Custom" } diff --git a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json index 6601fcf..b1deda4 100644 --- a/mod/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/mod/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -976,6 +976,8 @@ "setting.predictions.aim_trajectory": "Траектория для прицеливания", "setting.predictions.aim_indicator": "Индикатор прицеливания", "setting.predictions.tnt_timer": "Таймер TNT", + "setting.predictions.tnt_icon": "Иконка TNT", + "setting.predictions.tnt_fuse_bar": "Полоска фитиля TNT", "setting.predictions.tnt_color": "Цвет TNT", "setting.predictions.show_plate": "Показывать точку приземления", "setting.reimaginedvisual.night_mode.option.NIGHT": "Темная ночь", @@ -3423,5 +3425,50 @@ "module.rain.description": "Настраивает вид и звук дождя.", "module.spatialgui.description": "Рисует интерфейс плавающей панелью в 3D-пространстве.", "module.trinket.description": "Крепит декоративные безделушки к вашему персонажу.", - "module.wet_world.description": "Делает поверхности мокрыми во время дождя." + "module.wet_world.description": "Делает поверхности мокрыми во время дождя.", + "module.projectiletrails.description": "Рисует затухающие ленты за стрелами, жемчугом и другими снарядами.", + "setting.projectiletrails.duration": "Длительность (мс)", + "setting.projectiletrails.width": "Ширина", + "setting.projectiletrails.fill": "Заливка", + "setting.projectiletrails.fill_type": "Тип заливки", + "setting.projectiletrails.fill_type.option.Solid": "Сплошная", + "setting.projectiletrails.fill_type.option.Glow": "Свечение", + "setting.projectiletrails.lines": "Контур", + "setting.projectiletrails.line_width": "Толщина контура", + "setting.projectiletrails.depth_test": "Проверка глубины", + "setting.projectiletrails.color_mode": "Режим цвета", + "setting.projectiletrails.color": "Цвет", + "setting.projectiletrails.color2": "Второй цвет", + "setting.projectiletrails.color_speed": "Скорость цвета", + "setting.projectiletrails.projectiles": "Снаряды", + "setting.projectiletrails.color_mode.option.Static": "Статичный", + "setting.projectiletrails.color_mode.option.Rainbow": "Радуга", + "setting.projectiletrails.color_mode.option.LightRainbow": "Светлая радуга", + "setting.projectiletrails.color_mode.option.Sky": "Небо", + "setting.projectiletrails.color_mode.option.Fade": "Плавный", + "setting.projectiletrails.color_mode.option.DoubleColor": "Два цвета", + "setting.projectiletrails.color_mode.option.Analogous": "Аналоговый", + "setting.projectiletrails.color_mode.option.Theme": "Тема", + "setting.projectiletrails.projectiles.option.arrow": "Стрелы", + "setting.projectiletrails.projectiles.option.trident": "Трезубцы", + "setting.projectiletrails.projectiles.option.pearl": "Жемчуг Края", + "setting.projectiletrails.projectiles.option.snowball": "Снежки", + "setting.projectiletrails.projectiles.option.egg": "Яйца", + "setting.projectiletrails.projectiles.option.potion": "Зелья", + "setting.projectiletrails.projectiles.option.fireball": "Огненные шары", + "setting.projectiletrails.projectiles.option.wind_charge": "Ветряные заряды", + "setting.projectiletrails.projectiles.option.firework": "Фейерверки", + "setting.killeffect.mode.option.Shatter": "Раскол", + "setting.killeffect.mode.option.Ascend": "Вознесение", + "setting.killeffect.mode.option.Explosion": "Взрыв", + "setting.killeffect.mode.option.Disintegrate": "Распад", + "setting.killeffect.death_power": "Сила", + "setting.killeffect.death_duration": "Длительность (с)", + "setting.killeffect.death_spin": "Вращение", + "setting.killeffect.death_bounce": "Отскок от земли", + "setting.killeffect.death_sparks": "Искры", + "setting.killeffect.blast_color": "Цвет взрыва", + "setting.killeffect.blast_color.option.Natural": "Природный", + "setting.killeffect.blast_color.option.Client": "Цвет темы", + "setting.killeffect.blast_color.option.Custom": "Свой" } diff --git a/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysicsTest.java b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysicsTest.java new file mode 100644 index 0000000..934fb3f --- /dev/null +++ b/mod/src/test/java/dev/loki/lovisual/features/module/modules/visuals/effects/hits/death/DeathPhysicsTest.java @@ -0,0 +1,81 @@ +package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death; + +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Part; +import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Style; +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class DeathPhysicsTest { + + private static Part part(float y) { + return new Part(1, 0, y, 0, 0.25f, 0.375f, 0.125f); + } + + @Test + void shatterFallsAndRestsOnGround() { + Part p = part(1.1f); + DeathPhysics.launch(p, Style.SHATTER, 1.0f, 1.0f, 2.5f, 0, 1.0f, 0, new Random(1)); + for (int i = 0; i < 400; i++) DeathPhysics.step(p, Style.SHATTER, 0.02f, i * 0.02f, 0.0f, false); + assertEquals(0.375f, p.y, 1e-4f); + assertEquals(0.0f, p.vy, 1e-6f); + } + + @Test + void bounceKeepsPartAboveFloorAndLosesEnergy() { + Part p = part(1.5f); + p.vy = -6.0f; + float maxAfterFirst = 0; + boolean bounced = false; + for (int i = 0; i < 100; i++) { + DeathPhysics.step(p, Style.SHATTER, 0.02f, i * 0.02f, 0.0f, true); + assertTrue(p.y >= 0.375f - 1e-4f); + if (p.vy > 0) bounced = true; + if (bounced) maxAfterFirst = Math.max(maxAfterFirst, p.y); + } + assertTrue(bounced); + assertTrue(maxAfterFirst < 1.5f); + } + + @Test + void ascendRisesAndFadesOut() { + Part p = part(1.0f); + DeathPhysics.launch(p, Style.ASCEND, 1.0f, 1.0f, 2.5f, 0, 1.0f, 0, new Random(2)); + for (int i = 0; i < 50; i++) DeathPhysics.step(p, Style.ASCEND, 0.02f, i * 0.02f, 0.0f, false); + assertTrue(p.y > 1.4f); + assertEquals(1.0f, DeathPhysics.alpha(p, Style.ASCEND, 0.1f, 2.5f), 1e-6f); + assertEquals(0.0f, DeathPhysics.alpha(p, Style.ASCEND, 2.5f, 2.5f), 1e-6f); + } + + @Test + void disintegrateDissolvesTopFirst() { + Part head = new Part(0, 0, 1.75f, 0, 0.25f, 0.25f, 0.25f); + Part leg = new Part(4, 0, 0.375f, 0, 0.125f, 0.375f, 0.125f); + Random r = new Random(3); + DeathPhysics.launch(head, Style.DISINTEGRATE, 1f, 1f, 2.5f, 0, 1f, 0, r); + DeathPhysics.launch(leg, Style.DISINTEGRATE, 1f, 1f, 2.5f, 0, 1f, 0, r); + assertTrue(head.delay < leg.delay); + float t = (head.delay + leg.delay) * 0.5f + 0.3f; + assertTrue(DeathPhysics.alpha(head, Style.DISINTEGRATE, t, 2.5f) < DeathPhysics.alpha(leg, Style.DISINTEGRATE, t, 2.5f)); + } + + @Test + void explosionLaunchesFasterThanShatter() { + Part a = part(1.1f), b = part(1.1f); + a.x = 0.4f; + b.x = 0.4f; + DeathPhysics.launch(a, Style.SHATTER, 1f, 1f, 2.5f, 0, 1f, 0, new Random(5)); + DeathPhysics.launch(b, Style.EXPLOSION, 1f, 1f, 2.5f, 0, 1f, 0, new Random(5)); + assertTrue(Math.abs(b.vx) > Math.abs(a.vx)); + } + + @Test + void bodyHasSixPartsAroundFeet() { + var parts = DeathBody.build(10, 64, -5, 0, 1.8f); + assertEquals(6, parts.size()); + assertEquals(64 + 1.75f, parts.get(0).y, 1e-4f); + assertTrue(parts.get(2).x < parts.get(3).x); + } +} From e82efb74e7004441fcd60c323b349ee7a551b3cc Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 2 Oct 2026 13:41:49 +0200 Subject: [PATCH 05/10] feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history --- backend/.env.example | 1 + backend/Cargo.lock | 17 + backend/Cargo.toml | 2 +- backend/STRUCTURE.md | 6 +- backend/accounts-service/Dockerfile | 1 + backend/chat-service/Cargo.toml | 23 + backend/chat-service/Dockerfile | 20 + backend/chat-service/PLAN.md | 47 ++ backend/chat-service/README.md | 5 - backend/chat-service/src/config.rs | 45 + backend/chat-service/src/lib.rs | 27 + backend/chat-service/src/main.rs | 13 + backend/chat-service/src/presence/handlers.rs | 77 ++ backend/chat-service/src/presence/mod.rs | 20 + backend/chat-service/src/presence/payload.rs | 46 ++ backend/chat-service/src/presence/store.rs | 188 +++++ backend/chat-service/tests/presence.rs | 71 ++ backend/configs-service/Dockerfile | 1 + backend/docker-compose.prod.yml | 13 + backend/gateway/Dockerfile | 1 + backend/gateway/src/config.rs | 3 + backend/gateway/src/proxy/forward.rs | 3 + backend/gateway/src/proxy/routes.rs | 3 + backend/gateway/src/rate_limit/rules.rs | 2 + backend/gateway/tests/common/mod.rs | 1 + backend/scripts/dev-stack.sh | 13 +- mod/TODO.md | 21 +- mod/docs/TODO_HISTORY.md | 770 ++++++++++++++++++ .../screen/modules/ModulesMenuPresence.java | 53 ++ .../screen/modules/ModulesMenuScreen.java | 11 + .../screen/modules/model/MenuPresence.java | 10 + .../modules/visuals/presence/GuiPresence.java | 92 +++ .../presence/PresencePanelRenderer.java | 128 +++ .../visuals/presence/PresenceSampler.java | 49 ++ .../platform/presence/PresenceClient.java | 107 +++ .../platform/presence/PresenceCodec.java | 77 ++ .../platform/presence/PresenceTimeline.java | 87 ++ .../resources/assets/lovisual/lang/en_us.json | 7 +- .../resources/assets/lovisual/lang/ru_ru.json | 7 +- .../platform/presence/PresenceTest.java | 77 ++ server-plugin/.classpath | 25 + server-plugin/.project | 34 + .../org.eclipse.buildship.core.prefs | 13 + .../.settings/org.eclipse.jdt.core.prefs | 4 + 44 files changed, 2202 insertions(+), 19 deletions(-) create mode 100644 backend/chat-service/Cargo.toml create mode 100644 backend/chat-service/Dockerfile create mode 100644 backend/chat-service/PLAN.md delete mode 100644 backend/chat-service/README.md create mode 100644 backend/chat-service/src/config.rs create mode 100644 backend/chat-service/src/lib.rs create mode 100644 backend/chat-service/src/main.rs create mode 100644 backend/chat-service/src/presence/handlers.rs create mode 100644 backend/chat-service/src/presence/mod.rs create mode 100644 backend/chat-service/src/presence/payload.rs create mode 100644 backend/chat-service/src/presence/store.rs create mode 100644 backend/chat-service/tests/presence.rs create mode 100644 mod/docs/TODO_HISTORY.md create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/ModulesMenuPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/clickgui/layout/screen/modules/model/MenuPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/GuiPresence.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/PresencePanelRenderer.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/presence/PresenceSampler.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceClient.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceCodec.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/platform/presence/PresenceTimeline.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/platform/presence/PresenceTest.java create mode 100644 server-plugin/.classpath create mode 100644 server-plugin/.project create mode 100644 server-plugin/.settings/org.eclipse.buildship.core.prefs create mode 100644 server-plugin/.settings/org.eclipse.jdt.core.prefs diff --git a/backend/.env.example b/backend/.env.example index 3680a1d..83818c2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,7 @@ GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL=http://127.0.0.1:8081 ACCOUNTS_GRPC_URL=http://127.0.0.1:50051 CONFIGS_HTTP_URL=http://127.0.0.1:8082 +CHAT_HTTP_URL=http://127.0.0.1:8083 SITE_ORIGIN=http://localhost:5173 TRUST_PROXY=false # Directory the gateway serves under GET /downloads/* (lovisual.jar lives here; diff --git a/backend/Cargo.lock b/backend/Cargo.lock index f81faa7..14ba96d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -860,6 +860,23 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chat-service" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-test", + "common", + "dotenvy", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "chrono" version = "0.4.45" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ee98210..19db6ab 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "accounts-service", + "chat-service", "common", "configs-service", "gateway", @@ -9,5 +10,4 @@ members = [ # Planned members, added when their own implementation plan starts # (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3): -# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2) # "addons-registry" — addon marketplace backend (Подсистема 3) diff --git a/backend/STRUCTURE.md b/backend/STRUCTURE.md index 2d363e8..b010c42 100644 --- a/backend/STRUCTURE.md +++ b/backend/STRUCTURE.md @@ -17,9 +17,9 @@ backend/ configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов, # share-коды, витрина publish/browse/detail/copy # (Подсистема 1, часть 2) - chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, - # виджеты-телеметрия (Подсистема 2). Единственный - # сервис с WebSocket, а не только REST. + chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence + # (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия + # (Подсистема 2) позже, там будет WebSocket. addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии, # публикация/модерация (Подсистема 3) ``` diff --git a/backend/accounts-service/Dockerfile b/backend/accounts-service/Dockerfile index 5707085..bfd9abd 100644 --- a/backend/accounts-service/Dockerfile +++ b/backend/accounts-service/Dockerfile @@ -9,6 +9,7 @@ COPY common ./common COPY accounts-service ./accounts-service COPY gateway ./gateway COPY configs-service ./configs-service +COPY chat-service ./chat-service RUN cargo build --release -p accounts-service FROM debian:trixie-slim diff --git a/backend/chat-service/Cargo.toml b/backend/chat-service/Cargo.toml new file mode 100644 index 0000000..a4eeeea --- /dev/null +++ b/backend/chat-service/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "chat-service" +version = "0.1.0" +edition = "2024" + +[lib] +name = "chat_service" +path = "src/lib.rs" + +[dependencies] +common = { path = "../common" } +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +tracing = "0.1" +tracing-subscriber = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +anyhow = "1" +dotenvy = "0.15" + +[dev-dependencies] +axum-test = "21" diff --git a/backend/chat-service/Dockerfile b/backend/chat-service/Dockerfile new file mode 100644 index 0000000..6afa1a2 --- /dev/null +++ b/backend/chat-service/Dockerfile @@ -0,0 +1,20 @@ +# Build context must be backend/ (the workspace root). +FROM rust:1-trixie AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + protobuf-compiler pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY common ./common +COPY accounts-service ./accounts-service +COPY gateway ./gateway +COPY configs-service ./configs-service +COPY chat-service ./chat-service +RUN cargo build --release -p chat-service + +FROM debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /build/target/release/chat-service /usr/local/bin/chat-service +ENTRYPOINT ["/usr/local/bin/chat-service"] diff --git a/backend/chat-service/PLAN.md b/backend/chat-service/PLAN.md new file mode 100644 index 0000000..c418d53 --- /dev/null +++ b/backend/chat-service/PLAN.md @@ -0,0 +1,47 @@ +# chat-service + +Подсистема 2 платформы (RPC-чат, друзья, presence, телеметрия виджетов). Сейчас реализован только **GUI presence**; +чат и друзья позже (WebSocket). Сервис без БД: состояние в памяти, живёт секунды. + +## GUI presence (идея из evo `GuiPresence`, наш вариант) + +Зачем: LoVisual-игроки видят друг у друга над рукой мини-копию открытого меню клиента (какая категория, куда наведён курсор, +поиск), и знают, кто вообще пользуется LoVisual (значок у ника). Без видео и без чужих данных: только положение курсора и флаги. + +### Протокол +`POST /presence/gui` (через gateway, нужен device-токен `lvd_...`, gateway кладёт `x-lovisual-account-id`): + +```json +{ "server": "play.example.com", "mc": "", "gui": "|null", "want": ["", "..."], "publish": true } +``` +Ответ: `{ "players": [ { "mc": "", "gui": "|null", "age": 120 } ] }`, `age` в мс с последнего обновления. + +- Один запрос и публикует состояние вызывающего, и возвращает состояние тех, кого он видит (`want`, не больше 40). +- `publish = false` (по умолчанию true): только чтение, вызывающий сам не появляется у других. +- `gui = null` значит «меню закрыто, но LoVisual запущен» (значок у ника). Запись живёт 6 с без обновления. +- Игроки разных серверов не видят друг друга (ключ `server`, нормализуется в нижний регистр, ≤64 символов). +- Частота клиента: раз в ~350 мс при открытом меню (пачка до 8 сэмплов курсора по 100 мс), раз в 2 с при закрытом. + Лимит на gateway: 6 запросов в секунду на аккаунт. + +### Формат `gui` (36 байт максимум, base64 ≤48 символов; сервис его не разбирает, только проверяет алфавит и длину) +| байт | значение | +|---|---| +| 0 | версия = 1 | +| 1 | биты 0-3 категория (0..15), бит 4 открыта страница настроек, бит 5 поиск, бит 7 меню закрыто | +| 2 | индекс модуля под курсором в списке категории (255 = нет) | +| 3 | число сэмплов n (0..8) | +| 4+4i | сэмпл: x (0..255 по ширине меню), y (0..255 по высоте), бит 0 = зажата ЛКМ, прокрутка списка (0..255) | + +### Безопасность +- Идентичность только из заголовка gateway; прямой доступ закрыт общим секретом (как у остальных сервисов). +- Привязка `mc uuid` к аккаунту: чужой аккаунт не может публиковать за занятый uuid (409), привязка снимается через 10 мин + простоя; аккаунт владеет одним uuid, смена альта освобождает прежний. Это защита от подделки, не доказательство владения + аккаунтом Minecraft: серверной верификации uuid нет. +- Размер тела ≤8 КБ, payload только `[A-Za-z0-9+/=]`, `want` ≤40. + +### Структура +`src/presence/{payload.rs (валидация), store.rs (состояние + TTL + привязка), handlers.rs (sync)}`; тесты: модульные в `store.rs`, +`payload.rs`, интеграционные `tests/presence.rs`. Порт `CHAT_PORT` (8083), `INTERNAL_KEY` общий. В gateway маршрут `/presence/*`. + +### Не сделано +Просмотр видео (`.watch` у evo), чат и друзья (WebSocket), метрики, горизонтальное масштабирование (состояние в памяти одного процесса). diff --git a/backend/chat-service/README.md b/backend/chat-service/README.md deleted file mode 100644 index ef36036..0000000 --- a/backend/chat-service/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# chat-service (planned) - -RPC chat, friends, presence, telemetry widgets (Подсистема 2). WebSocket, not -just REST — see `TODO.md` (Фаза 10, "Подсистема 2") and -`backend/STRUCTURE.md`. No implementation plan yet. diff --git a/backend/chat-service/src/config.rs b/backend/chat-service/src/config.rs new file mode 100644 index 0000000..bd21e11 --- /dev/null +++ b/backend/chat-service/src/config.rs @@ -0,0 +1,45 @@ +use anyhow::{Context, Result}; + +#[derive(Clone)] +pub struct Config { + pub port: u16, + pub internal_key: String, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Config { + port: std::env::var("CHAT_PORT") + .unwrap_or_else(|_| "8083".into()) + .parse() + .context("CHAT_PORT")?, + internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?, + }) + } + + pub fn validate(&self) -> Result<()> { + if self.internal_key.len() < 32 { + anyhow::bail!("INTERNAL_KEY must be at least 32 bytes"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_key_is_rejected() { + let c = Config { + port: 0, + internal_key: "short".into(), + }; + assert!(c.validate().is_err()); + let c = Config { + port: 0, + internal_key: "k".repeat(32), + }; + assert!(c.validate().is_ok()); + } +} diff --git a/backend/chat-service/src/lib.rs b/backend/chat-service/src/lib.rs new file mode 100644 index 0000000..c500627 --- /dev/null +++ b/backend/chat-service/src/lib.rs @@ -0,0 +1,27 @@ +pub mod config; +pub mod presence; + +use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}}; +use common::internal::{InternalKey, require_internal_key}; +use config::Config; +use presence::store::Store; +use std::sync::Arc; + +/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids). +const MAX_BODY_BYTES: usize = 8 * 1024; + +pub fn build_app(cfg: &Config) -> (Router, Arc) { + let store = Arc::new(Store::default()); + let api = Router::new() + .route("/presence/gui", post(presence::handlers::sync)) + .with_state(Arc::clone(&store)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .layer(axum::middleware::from_fn_with_state( + InternalKey::new(cfg.internal_key.clone()), + require_internal_key, + )); + let app = Router::new() + .route("/health", get(|| async { "ok" })) + .merge(api); + (app, store) +} diff --git a/backend/chat-service/src/main.rs b/backend/chat-service/src/main.rs new file mode 100644 index 0000000..47e3ab2 --- /dev/null +++ b/backend/chat-service/src/main.rs @@ -0,0 +1,13 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt::init(); + let cfg = chat_service::config::Config::from_env()?; + cfg.validate()?; + let (app, state) = chat_service::build_app(&cfg); + chat_service::presence::spawn_purger(state); + let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?; + tracing::info!("chat-service listening on {}", cfg.port); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/backend/chat-service/src/presence/handlers.rs b/backend/chat-service/src/presence/handlers.rs new file mode 100644 index 0000000..bc962ad --- /dev/null +++ b/backend/chat-service/src/presence/handlers.rs @@ -0,0 +1,77 @@ +use super::{ + payload::{MAX_WANT, normalize_server, valid_payload}, + store::{PublishError, Store}, +}; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use common::internal::GatewayIdentity; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::{sync::Arc, time::Instant}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct SyncRequest { + pub server: String, + pub mc: Uuid, + /// Base64 GUI payload, or null when this client has no menu open (still + /// marks the player as a LoVisual user for a few seconds). + #[serde(default)] + pub gui: Option, + /// Minecraft uuids of nearby players the caller wants state for. + #[serde(default)] + pub want: Vec, + /// False for a read-only poll: the caller sees others but does not appear itself. + #[serde(default = "yes")] + pub publish: bool, +} + +fn yes() -> bool { + true +} + +#[derive(Serialize)] +pub struct PlayerState { + pub mc: Uuid, + pub gui: Option, + pub age: u64, +} + +fn error(status: StatusCode, message: &str) -> Response { + (status, Json(json!({ "error": message }))).into_response() +} + +/// One round trip both publishes the caller's state and returns the state of +/// the players the caller can see (same request as the mod's poll loop). +pub async fn sync( + State(store): State>, + id: GatewayIdentity, + Json(req): Json, +) -> Response { + let Some(server) = normalize_server(&req.server) else { + return error(StatusCode::BAD_REQUEST, "invalid server"); + }; + if req.want.len() > MAX_WANT { + return error(StatusCode::BAD_REQUEST, "too many players requested"); + } + if req.gui.as_deref().is_some_and(|g| !valid_payload(g)) { + return error(StatusCode::BAD_REQUEST, "invalid gui payload"); + } + let now = Instant::now(); + if req.publish + && let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now) + { + return error(StatusCode::CONFLICT, "player uuid is bound to another account"); + } + let players: Vec = store + .lookup(&server, &req.want, now) + .into_iter() + .filter(|s| s.mc != req.mc) + .map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms }) + .collect(); + Json(json!({ "players": players })).into_response() +} diff --git a/backend/chat-service/src/presence/mod.rs b/backend/chat-service/src/presence/mod.rs new file mode 100644 index 0000000..ebc1a22 --- /dev/null +++ b/backend/chat-service/src/presence/mod.rs @@ -0,0 +1,20 @@ +//! GUI presence: lets LoVisual users see which client menu nearby LoVisual +//! users have open. State is in-memory and short-lived (a few seconds), never +//! persisted; see `backend/chat-service/PLAN.md`. + +pub mod handlers; +pub mod payload; +pub mod store; + +use std::{sync::Arc, time::{Duration, Instant}}; +use store::Store; + +pub fn spawn_purger(store: Arc) { + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(30)); + loop { + tick.tick().await; + store.purge(Instant::now()); + } + }); +} diff --git a/backend/chat-service/src/presence/payload.rs b/backend/chat-service/src/presence/payload.rs new file mode 100644 index 0000000..2054187 --- /dev/null +++ b/backend/chat-service/src/presence/payload.rs @@ -0,0 +1,46 @@ +//! Validation of the opaque GUI payload and server key. The payload is a +//! base64 string produced by the mod (header + up to 8 cursor samples); the +//! service never decodes it, it only bounds what it stores and relays. + +/// Header (4 bytes) + 8 samples x 4 bytes = 36 bytes = 48 base64 characters. +pub const MAX_PAYLOAD_CHARS: usize = 48; +pub const MAX_SERVER_CHARS: usize = 64; +pub const MAX_WANT: usize = 40; + +pub fn valid_payload(s: &str) -> bool { + !s.is_empty() + && s.len() <= MAX_PAYLOAD_CHARS + && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')) +} + +/// Normalised server key: trimmed, lower-case, no control characters, bounded. +pub fn normalize_server(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + if s.is_empty() || s.chars().count() > MAX_SERVER_CHARS || s.chars().any(|c| c.is_control()) { + return None; + } + Some(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_charset_and_length() { + assert!(valid_payload("AQEAAAA=")); + assert!(!valid_payload("")); + assert!(!valid_payload("has space")); + assert!(!valid_payload("