feat(mod): scripted HoldMyItems on LuaJ (hand/item/model scripts, addons, vanilla overrides, particles, camera)

Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
loki5512344 2026-10-02 09:47:30 +02:00
parent 44353e893c
commit 0504be5b57
Signed by: boba
GPG key ID: 253067914055423B
21 changed files with 750 additions and 139 deletions

View file

@ -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` падает при нарушении),
без длинного тире в текстах сайта и ответах.

View file

@ -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<Float> gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get);
public final NumberValue<Float> 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());
}

View file

@ -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/<name>.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;
}
}
}

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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<P> 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);
}
}
}

View file

@ -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<Op> 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<BakedQuad> apply(List<BakedQuad> quads) {
lastQuadCount = quads.size();
if (ops.isEmpty()) return quads;
List<BakedQuad> 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());
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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/<name>.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";
}
}

View file

@ -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();
}
}

View file

@ -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();

View file

@ -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<BakedQuad> lovisual$animateQuads(List<BakedQuad> quads) {
return ModelAnimator.INSTANCE.apply(quads);
}
}

View file

@ -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]);
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)",

View file

@ -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)",

View file

@ -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",

View file

@ -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);
}
}