feat: frontend perf and a11y pass, plus mod visuals, hero main menu, Simple Voice Chat and vanilla chat autocomplete

Frontend:
- replace the i18next stack with a small typed i18n module
- hero clouds removed, llms.txt, HUD widget ARIA fixes and tests

Mod:
- TargetESP constellation mode; tracker and colors extracted
- JumpCircles: 7 new shapes, shared animation and color pipeline
- KillEffect blood mode with Burst/Fountain/Ring/Spray styles
- main menu: menuLayout setting (orbs/hero) and hero screen
- Simple Voice Chat soft integration: VoiceChat module and HUD panel
- renderer/quick: static Draw facade with glass outline
- vanilla chat: Baritone-style autocomplete popup for the % command prefix
This commit is contained in:
loki5512344 2026-10-01 16:22:05 +02:00
parent 167405aed0
commit e00ea8eb11
Signed by: boba
GPG key ID: 253067914055423B
204 changed files with 6469 additions and 833 deletions

View file

@ -591,3 +591,149 @@
### Итог: ничего из 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`
подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `<module>`
показываются серым текстом (`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.<id>.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 <player>`: не было `suggest` (подсказка показывала только серый `<player>`): теперь ники игроков и режимы
(`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: `hand_relative_pose`, аддоны (`*_addon.lua`), подмена ванильного equip/swing скриптом,
обе руки всегда, ItemStack-поля. Не проверено вживую: направление осей M:rotate на реальной руке.
- [ ] 1b. HMI хвост (пункты 9-16): счётчики свинга и события, обе руки, ItemStack-поля, use_duration/using_item,
аниматор частей модели (`item_model.lua`), камера из Lua, 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.
### Не проверено вживую / открытые вопросы
- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `<module>` после `%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 файлов >200 строк), по мере касания.
- [ ] Приёмка серверного плагина (Фаза 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/`.
- Лимиты проекта: ≤200 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении),
без длинного тире в текстах сайта и ответах.

View file

@ -34,6 +34,10 @@ repositories {
// ViaBedrock snapshots (alt repo used by Via* stack)
url = 'https://maven.lenni0451.net/snapshots'
}
maven {
// Simple Voice Chat API (optional compat)
url = 'https://maven.maxhenkel.de/repository/public'
}
maven {
// JitPack (for com.github.* artifacts like CubeConverter)
url = 'https://jitpack.io'
@ -130,6 +134,9 @@ dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
// Loader 0.19.4 ships MixinExtras 0.5.4, which crashes on @Redirect(at) arrays with this Mixin; 0.5.5 handles them.
implementation "io.github.llamalad7:mixinextras-fabric:${project.mixinextras_version}"
include "io.github.llamalad7:mixinextras-fabric:${project.mixinextras_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
@ -155,6 +162,9 @@ dependencies {
runtimeOnly "com.viaversion:viafabricplus:${project.viafabricplus_version}"
}
// Simple Voice Chat (optional): client plugin API for speaking indicators; the mod provides it at runtime.
compileOnly "de.maxhenkel.voicechat:voicechat-api:${project.voicechat_api_version}"
// Entity Culling (optional): typed compile-time API for shader ESP visibility compat.
compileOnly "maven.modrinth:entityculling:${project.entityculling_version}"
@ -175,6 +185,10 @@ dependencies {
// Javet core only. Native V8 binaries are copied into resources/javet/natives.
bundledLibrary "com.caoccao.javet:javet:${javetVersion}"
// LuaJ: scripted hand/item poses for HoldMyItems (MIT, pure Java interpreter).
bundledLibrary "org.luaj:luaj-jse:3.0.1"
testImplementation "org.luaj:luaj-jse:3.0.1"
// Javet in minimal V8 mode only: no Node runtime and no ARM native payloads.
// ARM builds can be added later behind a separate opt-in distribution profile.
javetNativeBundle "com.caoccao.javet:javet-v8-windows-x86_64:${javetVersion}"

View file

@ -0,0 +1,18 @@
{
"enabled": false,
"layout": {
"pos": [
16.0,
150.0
],
"space": "logical"
},
"voicechat_scale": 1.0,
"voicechat_layout": "Split Header",
"voicechat_max_rows": 6.0,
"voicechat_hold_ms": 350.0,
"voicechat_show_self": true,
"voicechat_bg_alpha": 131.0,
"voicechat_bg_effect": "Auto",
"voicechat_blur_alpha": 255.0
}

View file

@ -1,20 +1,12 @@
{
'debug': 'off',
'forcePvp': false,
'menuBg': 'off',
'menuLayout': 'orbs',
'menuClockShowSeconds': false,
'menuBgUseTheme': false,
'msaa3d': 'off',
'clickGuiModulesHints': true,
'clickGuiHudEditorHints': true,
'inventorySwapPolicy': 'none',
'inventorySwapScope': 'full',
'inventorySwapVisibility': 'silent',
'inventorySwapRestore': true,
'inventorySwapPreferHotbar': true,
'inventorySwapLegitWaitTicks': 2.0,
'inventorySwapStrictInventoryWaitTicks': 1.0,
'inventorySwapStrictMovementLockTicks': 2.0,
'disableNarrator': true,
'backdoorProtection': true,
'backdoorTranslationFilter': true,

411
mod/docs/EVO_REPORT.md Normal file
View file

@ -0,0 +1,411 @@
# Отчёт по клиенту EvoVis (evo) для LoVisual
Дата: 2026-10-01. Режим: только чтение, в проекте ничего не менялось, кроме этого файла.
Источники (все пути абсолютные):
- Деобфусцированный мод evo: `/storage/project/jvm/LoVisual/ref/evo/mod/jar-src-named/evovis/` (читаемые имена классов, 1328 штук) и `/storage/project/jvm/LoVisual/ref/evo/mod/jar-src-plain/sources/defpackage/` (оригинальные короткие имена `afe.java` и т.п.). Исходники ещё с control-flow flattening (`switch (i + i2)`), логику читать можно, но вокруг много шума.
- Ресурсы evo (распакованный jar): `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/` (пространства имён `evolution` и `minecraft`).
- Каталоги модулей: `/storage/project/jvm/LoVisual/ref/evo/analysis/modules.txt` (57 модулей) и `lovisual_modules.txt` (88 наших).
- Таблица соответствия имён: `/storage/project/jvm/LoVisual/ref/evo/analysis/class_map.tsv` (старое имя, новое имя, роль).
- Чистый (не обфусцированный) референс HoldMyItems с Lua: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/com/holdmylua/` и миксины `/storage/project/jvm/LoVisual/ref/clients/mercury-src/fun/mercury/mixin/hmi/` + `HeldItemRendererMixin.java`.
- Наш мод: `/storage/project/jvm/LoVisual/mod/src/main/java/dev/loki/lovisual/`.
ВАЖНОЕ ЗАМЕЧАНИЕ О ЛИЦЕНЗИИ. `ref/evo/README.md` прямо говорит: код и ассеты EvoVis проприетарные, "в LoVisual ничего не переносим, смотрим подходы и идеи". Поэтому копировать модели/текстуры/звуки/Lua evo в релиз как есть нельзя без разрешения. Форматы там стандартные (Bedrock geo.json + animation.json из Blockbench, Lua-скрипты формата HoldMyItems), так что реализацию можно делать на своих ассетах, а пути ниже нужны как карта и как тестовый материал для локальной отладки. Ниже в разделах 2 и 3 "пути для копирования" означают пути источников, которые нужны на время отладки. Для публичного релиза нужны либо разрешение, либо свои модели в тех же форматах.
---
## 1. Модули evo и чего нет у нас
Всего у evo 57 модулей (`evovis/module/{builders,combat,cosmetics,misc,render}`), у нас 88. Список наших взят из `@ModuleInfo` в `features/module/modules/*`.
Обозначения: сложность S (до дня работы), M (несколько дней), L (неделя и больше или нужна новая инфраструктура). Приоритет P1 (делать первым), P2, P3. "LOC" это размер класса модуля в evo (грубая оценка объёма; сам модуль часто тянет ещё `evovis/internal/*`).
### 1.1 Что у нас УЖЕ есть (аналог найден, переносить не нужно)
| Модуль evo | Наш аналог |
|---|---|
| Crosshair | `visuals/player/Crosshair` |
| HitBox | `visuals/visibility/Hitboxes` |
| Predictions | `combat/prediction/Predictions` |
| AspectRatio | `visuals/camera/AspectRatio` |
| BetterMinecraft | `misc/vanilla/BetterMinecraft` |
| BlockOverlay | `visuals/scene/BlockHighlight` (у evo есть ещё шейдерные режимы, см. 1.3) |
| Chams | `visuals/scene/visual/Chams` |
| HMI | `visuals/items/held/HoldMyItems` (урезанный аналог, раздел 4) |
| JumpCircles | `visuals/player/JumpCircles` |
| MotionBlur | `visuals/fx/MotionBlur` |
| NameTags | `visuals/nametags/NameTags` |
| TargetESP | `combat/prediction/TargetESP` |
| Trails | `visuals/effects/ambient/Trails` |
| WorldLyrics | `visuals/lyrics/KineticLyrics` |
| WorldTweaks | `visuals/weather/WorldTweaks` |
| Zoom | `visuals/fx/Zoom` |
| ItemScroller | `misc/items/ItemScroller` |
| NoRender | `visuals/visibility/NoRender` |
| Menu / Interface | `misc/ClickGui` и HUD-система `features/gui/hud/*` |
| BetterRain | `visuals/weather/precip/Rain`, `WetWorld` |
| FireFly | `visuals/particles/AmbientParticles` (там есть firefly) |
| Particles (на удар, тотем) | `visuals/effects/hits/HitEffect`, `visuals/fx/TotemFX` |
| Waypoints | частично: `visuals/world/Gps` + Xaero-совместимость `compat/xaero/*` |
### 1.2 Чего у нас НЕТ (полный список, 29 модулей)
| Категория | Модуль evo | Суть | LOC | Сложность | Приоритет |
|---|---|---|---|---|---|
| BUILDERS | Chunk Cache | держит посещённые чанки загруженными за пределами server view distance | 314 | M/L (клиентский ChunkCache, совместимость с Sodium) | P3 |
| BUILDERS | Schematics | призрачный предпросмотр и список материалов для litematic/schem/nbt | 1258 | L | P3 |
| BUILDERS | Showcase | находит постройку, летит кинематографичным путём вокруг и сохраняет клип | 882 | L (камера-путь это M, запись клипа это L) | P2 |
| COMBAT | HitFix | атаки по хитбоксу, который реально видишь, а не на тик вперёд | 665 | M (трогает сетевую логику атаки, риск античита) | P3 |
| COMBAT | TNTTimer | остаток фитиля у зажжённого TNT | 324 | S | P1 |
| COSMETICS | Cosmetics | 3D-косметика и плащи на игроках (крылья, рюкзаки, скины-модели, плащ) | 46 + `internal/Cosmetic*` | L в целом, M только для крыльев (раздел 2) | P1 |
| COSMETICS | Melee Skins | анимированные скины (ножи) на удерживаемых мечах | 860 | L (раздел 3) | P1 |
| COSMETICS | Camera | клиентская камера и штатив: кадр, подбег, фото | 1082 | M/L | P3 |
| COSMETICS | Graffiti | зажать клавишу и наспреить граффити на блок | 1010 | M | P3 |
| COSMETICS | PatPat | пустая рука на мобе, зажать клавишу, погладить | 645 | M | P2 |
| COSMETICS | Skin Wheel | поднять запястный циферблат (Omnitrix), крутить скины со вспышкой | 341 | M (нужны полные модели скинов, `cosmetic/skins`) | P3 |
| MISC | AutoSprint | держит спринт, когда разрешено | 19 | S (у нас уже есть `SprintControlEvent`) | P1 |
| MISC | TotemCounter | счётчик сломанных тотемов и своих | 255 | S | P1 |
| MISC | TotemSound | свой звук при срабатывании тотема | 257 | S | P2 |
| MISC | Sounds | звук на каждое клиентское действие | 195 | S | P2 |
| MISC | InventorySort | сортировка инвентаря по пресету | 390 | S/M | P2 |
| MISC | StorageTracker | запоминает содержимое контейнеров, ищет предметы | 678 | M | P2 |
| MISC | Discord RPC | статус в Discord | 56 | S (но нужна IPC-библиотека, зависимость) | P3 |
| MISC | Replay | хранит последние секунды геймплея, пишет клипы, тримминг | 526 + jcodec, `Evoclip` | L | P3 |
| MISC | Trainer | тренировочные дрели: прицел, тотем, якорь | 402 | M | P3 |
| RENDER | ArmorColor | красит надетую броню по остаточной прочности | 353 | S/M (у нас есть `EquipmentLayerRendererMixin`) | P2 |
| RENDER | Blizzard | высотная метель: ветровые снежинки, белая пелена, обмерзание краёв | 1290 | M/L | P2 |
| RENDER | ConsumeESP | круг с кольцом прогресса, что цель ест или пьёт | 671 | M | P2 |
| RENDER | CustomHand | пост-процесс шейдер руки | 373 | M (у нас есть `shaders/hand_glass.frag`, частично) | P2 |
| RENDER | DeathEffects | игроки рассыпаются на части тела при смерти | 2789 | L | P1 (одна из самых эффектных) |
| RENDER | DynamicLights | факелы и лампы в руке светят по-настоящему | 553 | M (проверить, что наш `Light` это не оно) | P2 |
| RENDER | ElytraTrails | светящиеся шлейфы с концов крыльев элитры | 2208 | M (переиспользовать наш `Trails`) | P2 |
| RENDER | EnchantGlint | замена блеска зачарования стилями | 274 | S/M | P1 |
| RENDER | FoodInfo | сытость, восстановление, подсказка оптимального порядка еды на полосе голода | 889 | M | P2 |
| RENDER | GodRays | лучи солнца сквозь листву и рельеф | 33 + шейдер `shaders/core/godrays` | M | P1 |
| RENDER | GuiPresence | показывает меню Evolution/YouTube, которое держат другие пользователи | 1577 | L (нужен бэкенд evo) | пропустить |
| RENDER | PlaceAnimation | поставленные блоки плавно скользят и наклоняются на место | 195 | S/M | P1 |
| RENDER | ProjectileTrails | гаснущие шлейфы за стрелами, жемчугом и прочими снарядами | 1401 | M (переиспользовать `Trails`) | P1 |
Итого отсутствует 29 из 57. Из них 3 из BUILDERS, 6 из COSMETICS, 12 из MISC (включая S-модули), остальное из RENDER и COMBAT.
### 1.3 Самое крутое (рекомендуемый порядок)
1. **Melee Skins (ножи)**: 33 скина, полные анимации draw/inspect/атаки/бег, звуки CS2-стиля, отдельный шейдер "holy". Раздел 3.
2. **Крылья из моделей** (14 видов, часть с анимацией). У нас уже есть модуль Wings, нужно добавить режим моделей. Раздел 2.
3. **DeathEffects**: распад тела на части (в evo есть шейдер `assets/evolution/shaders/core/death_blast`).
4. **GodRays**: готовые референс-шейдеры в `assets/evolution/shaders/core/godrays/`.
5. **Blizzard** и **ElytraTrails/ProjectileTrails**: хорошо ложатся на наш рендер-движок (`Renderer3D`, `MeshBuilder`).
6. **PlaceAnimation**, **EnchantGlint**, **ArmorColor**: маленькие, дают заметный визуальный эффект.
7. **Showcase** + **Camera** (кинематографика), **PatPat**, **Skin Wheel**, **Graffiti** (социальные косметики).
8. Дополнительно для существующего BlockHighlight: шейдерные режимы оверлея блока у evo (`assets/evolution/shaders/core/blockoverlay/{starfield,holy,plasma,nebula,cobweb}`).
Прочие ресурсы evo, которые могут пригодиться как референс: `assets/evolution/shaders/core/*` (liquid_glass, kawase_up/down blur, motionblur, godrays, hand, hand_smoke, rain, sky_*, customsky, chams_*, wetworld, trail_glass), `assets/evolution/fonts/msdf/`, `assets/evolution/textures/{jumpcircles,targetesp,mob_effect,particle}`, `assets/evolution/videos/menu_bg.mp4`.
---
## 2. Крылья (wings)
### 2.1 Где лежат и в каком формате
Формат: Bedrock Blockbench. Модель это `*.geo.json` (корень `minecraft:geometry`, `format_version` 1.12.0, у `easter` 1.21.0), анимация это `*.animation.json` (Bedrock animation, линейные keyframe, без Molang). Форматов obj/bbmodel/Geckolib у крыльев нет, свой парсер.
Каталоги (источник):
- Модели и анимации: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/wings/` (13 geo, 10 animation, всего 340 КБ).
- Текстуры: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/textures/cosmetic/wings/` (14 png, 88 КБ).
Точные файлы:
geo.json: `angel`, `blue_dragon`, `dark`, `demon`, `dragon`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`.
animation.json: `blue_dragon`, `dark`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`. У `angel`, `demon`, `dragon` анимации в файлах нет, они процедурные в коде (см. 2.3).
png: `angel_white`, `baby_dragon_white`, `blue_dragon`, `dark`, `demon_black`, `demon_red`, `easter`, `ghoul`, `pulse`, `rocker`, `spider`, `trident`, `vulcano`, `wota`.
### 2.2 Каталог: 14 видов
Каталог задан статически в `evovis/hud/WingsHud.java` (оригинал `defpackage/afe.java`), блок `static { WINGS.add(new Cosmetic5(key, name, DragonMode)) }`. Параметры привязки заданы в `evovis/internal/DragonMode.java` (оригинал `afc.java`): scale, offsetY, offsetZ. Для режимов без явных значений: scale 1.0, offsetY 0.45, offsetZ 0.0.
| key (id) | Название | geo | Текстура (размер png) | Анимация | scale / offsetY / offsetZ |
|---|---|---|---|---|---|
| baby_dragon_white | Baby Dragon (White) | dragon | baby_dragon_white.png (64x64) | процедурная `dragon_wings` | 1.0 / 0.45 / 0 |
| angel_white | Angel (White) | angel | angel_white.png (516x774) | процедурная `angel_wings` | 1.0 / 0.45 / 0 |
| demon_red | Demon (Red) | demon | demon_red.png (516x774) | процедурная `demon_wings` | 1.0 / 0.45 / 0 |
| demon_black | Demon (Black) | demon | demon_black.png (516x774) | процедурная `demon_wings` | 1.0 / 0.45 / 0 |
| pulse | Pulse Wings | pulse | pulse.png (32x32) | pulse.animation.json (idle, 3 с, 4 кости) | 1.0 / -1.2 / 0 |
| dark | Dark Wings | dark | dark.png (64x64) | dark.animation.json (idle, 4 с, 6 костей) | 0.9 / -1.05 / 0.05 |
| ghoul | Ghoul Wings | ghoul | ghoul.png (64x64) | ghoul.animation.json (Idle, 3 с, 21 кость) | 0.6 / -0.65 / 0 |
| rocker | Rocker Wings | rocker | rocker.png (128x128) | rocker.animation.json (main, 2.4 с, 25 костей) | 0.9 / -1.05 / 0 |
| wota | Wota Wings | wota | wota.png (64x640, 10 кадров по 64) | wota.animation.json (idle, 3 с, 8 костей) | 0.9 / -1.05 / 0 |
| trident | Trident Wings | trident | trident.png (32x32) | trident.animation.json (idle, 3.2 с, 1 кость) | 0.9 / -1.05 / 0.2 |
| easter | Easter Wings | easter | easter.png (128x128) | easter.animation.json (idle, 3 с, 19 костей) | 0.9 / -1.05 / 0 |
| blue_dragon | Dragon Wings (Blue) | blue_dragon | blue_dragon.png (128x128) | blue_dragon.animation.json (idle, 1.8 с, 5 костей) | 0.9 / -1.05 / 0 |
| spider | Spider Wings | spider | spider.png (256x256) | spider.animation.json (idle, 2.4 с, 5 костей) | 1.0 / 0 / 0 |
| vulcano | Vulcano Wings | vulcano | vulcano.png (64x64) | vulcano.animation.json (idle, 1.54 с, 4 кости) | 1.0 / 0 / 0 |
Размер png не равен `texture_width/height` в geo (текстуры в 2x выше). UV в geo задаются в пикселях геометрии, поэтому нормировать надо делением на `description.texture_width/texture_height` из geo, а не на размер png.
Число костей/кубов по geo: angel 15/12, blue_dragon 8/52, dark 9/14, demon 7/14, dragon 5/12, easter 45/158, ghoul 25/28, pulse 7/12, rocker 58/186, spider 11/94, trident 1/5, vulcano 4/14, wota 21/50.
Особый случай `wota`: анимированная текстура. `wota.png` это 10 кадров по 64 px высотой (64x640), смена кадра каждые 150 мс (`WOTA_FRAME_HEIGHT = 64`, `WOTA_FRAME_MILLIS = 150`, регистрация через `Animated.register` в `evovis/internal/Animated.java`, `Cosmetic5.getTexture()` дёргает `Animated.tick()`). У нас проще: брать смещение V = frame * (64/640).
Запасные режимы `AVIAN` и `INSECTOID` в `DragonMode` в каталог не попали, это встроенные JSON-анимации-заглушки в `WingsHud` (`AVIAN_ANIM`, `INSECTOID_ANIM`) для кастомных моделей. Нам не нужны.
### 2.3 Как рендерятся
Цепочка в evo:
1. Каталог и загрузка JSON: `evovis/hud/WingsHud.java` (`afe`). `geometry(mode)` читает `/assets/evolution/cosmetic/wings/<geometry>.geo.json` из classpath, `bundledAnimation(mode)` читает `<geometry>.animation.json`. Для `DRACONIC/ANGELIC/DEMONIC` вместо файла подставляется `{"procedural":"dragon_wings"}` и т.п.
2. Экипировка: `WingsHud.applyKey(playerName, key)` собирает JSON "косметики" (`{"name":"wing_<key>","id":..., "attachment":1,"scale":..,"offset":[0,offsetY,offsetZ],"rotation":[0,0,0],"animation":..,"model":<geo>}`) и регистрирует через `Internal34.registerWithTexture(json, textureId)`. `attachment: 1` это `AttachmentMode.BODY` (`evovis/internal/AttachmentMode.java`: FREE, BODY, HEAD, ABOVE_HEAD, RIGHT_ARM, LEFT_ARM, RIGHT_LEG, LEFT_LEG, FULL_BODY). Менеджер косметики: `evovis/internal/Internal34.java` (`adq`), дескриптор: `Internal35.java` (`adr`).
3. Слой рендера игрока: `evovis/internal/Internal33.java` (`adp`), `RenderLayer<AvatarRenderState, PlayerModel>`. Для каждой косметики вызывает `Controller.render(...)`, пропускает невидимых игроков.
4. Позиционирование: `evovis/internal/Controller.java`. Порядок: `bind(poseStack)`, `push`, привязка к части тела `applyAttachment` (для BODY: translate+rotate по `playerModel.body`, поэтому крылья следуют за наклоном при присяде; возвращает сдвиг -0.3), затем `rotateZ(180)`, `translate(offX, offY + attachmentShift, offZ + backOffset)`, `rotateY/X/Z(косметика)`, `scale(scale)`. `backOffset` отодвигает крылья от спины при надетой броне: 0.078125 для нагрудника, 0.1375 для крыльев поверх нагрудника (`WINGS_BACK_OFFSET`). Крылья скрываются при глайдере (элитре) на груди (`DataComponents.GLIDER`).
5. Геометрия: `evovis/hud/ModelRendererHud.java` (`adv`). `render()` вызывает `submitNodeCollector.submitCustomGeometry(poseStack, RenderTypes.entityTranslucent(texture), ...)`, рекурсивно `renderPart(bone)`: translate offset, translate pivot, rotate, scale, translate pivot back, затем кубы (`renderCube`: на каждую из 6 граней по 4 вершины с позицией, UV, нормалью, `OverlayTexture.NO_OVERLAY`, light игрока).
6. Парсер геометрии: `evovis/internal/Rotation2.java` (`aid`), `Definition.java` (`aif`: textureWidth/Height, roots), `Parent.java` (`aig`: кость, pivot, rot, offset, scale, cubes, children).
Правила парсинга Bedrock geo в `Rotation2` (их надо воспроизвести):
- Кость: `pivot` читается как `(-x, y, z)`. `rotation` как `(rad(-rx), rad(-ry), rad(rz))`. Кубы внутри кости: `origin`, `size`, `pivot` (по умолчанию = origin), `rotation`, `inflate`, `mirror`; тот же поворот знаков.
- UV куба: либо box-UV массив `[u,v]` (стандартная раскладка Bedrock), либо per-face объект `{north:{uv,uv_size,uv_rotation},...}`; `uv_rotation` допустим только 0/90/180/270. Есть эвристика `collectFrontRects` для плоских кубов нулевой толщины (dedupe прямоугольников "south" против "north").
- Размеры в пикселях, масштаб 1/16 блока.
Анимации:
- Файловые (`*.animation.json`): корень либо `{"animations":{...}}`, либо плоский `{"idle":{...}}` (так у dark, pulse, trident, vulcano, wota; а у blue_dragon, easter, spider корень плоский, но имя клипа `animation.xxx.idle`). Берётся первый клип. Каналы: `rotation`, `position`, `scale`, значения `time -> [x,y,z]`, интерполяция линейная (`ModelRendererHud.interpolate`). Время: `(System.currentTimeMillis() - start) / 1000 % animation_length`. Применение (`ModelRendererHud.applyAnimation`): `rotX = base + rad(-x)`, `rotY = base + rad(-y)`, `rotZ = base + rad(z)`; `offset = base + position`; `scale = base * scale`. Базовые позы костей снимаются один раз (`snapshotBasePose`) и перед каждым кадром восстанавливаются (`resetPart`).
- Процедурные (angel, dragon, demon): `ModelRendererHud.applyProceduralAnimation` выбирает по имени: `angel_wings` это `Internal29` (`defpackage/adg.java`), `dragon_wings` это `Internal42` (`aei.java`), `demon_wings` это `Internal41` (`aeh.java`). Время `lifeTime = ((tickCount + partialTick) / 20) % 360`. Внутри обычные `cos/sin` от градусов на костях: angel (Left_Wing, Right_Wing, bone2..bone11; корневой масштаб 0.6), demon (Wing2, Wing3, bone2, bone3, bone8, bone9; множитель времени 200), dragon (left_wing, right_wing, left_tip, right_tip; частоты 120/300/600/2200). Формулы лежат в этих трёх классах в явном виде.
Вывод: у evo крылья не зависят от позы игрока (нет режима полёта/присяда, кроме следования за телом). Анимация общая по часам, а не по игроку.
### 2.4 Наш модуль Wings и как добавить режим "модель из evo"
Наш модуль: `/storage/project/jvm/LoVisual/mod/src/main/java/dev/loki/lovisual/features/module/modules/visuals/wings/`
- `Wings.java` (`@ModuleInfo(id="wings", category=ACCESSORIES)`): настройки `wingsType` (ModeValue из `WingShapes.TYPES`: Angelic, Dragon, Butterfly, Phoenix, Crystal, Mechanical, Fairy, Demon), target (Self/Friends/All Players), scale, flapping, flapStrength, flapSpeed, throughWalls, glow, outline, ribs, color. Фаза `WorldPhase.END_MAIN`, метод `onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta)`. Свой игрок показывается только не от первого лица и без элитры.
- `WingsRenderer.java`: рисует мировые вееры треугольников через `Renderer3D.batch(...)` и joml-матрицу `translate(player) · rotY(180-bodyYaw) · pre-translate · rotX(pitch) · rotZ(roll) · translate(anchor) · scale · [сторона]...`. Есть `resolvePose` (idle/sneak/glide).
- `WingShapes.java`: данные 8 силуэтов и пресеты поз (`POSE_IDLE`, `POSE_SNEAK`, `glidePose`).
- Тесты: `src/test/java/.../wings/WingShapesTest.java`, `WingsRendererTransformTest.java`.
Текстурный рендер в нашем движке уже есть и ровно подходит: `Renderer3D.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_DEPTH | WORLD_TEXTURED, Identifier texture, Renderer3D.DepthMode.MAIN | NONE)` и запись вершин `mesh.vec3(x,y,z).vec2(u,v).color(r,g,b,a).next()`, затем `mesh.quad(i1,i2,i3,i4)`. Пример: `features/module/modules/visuals/trollface/TrollfaceMask.java` строки 125-132. Текстуры берутся по `Identifier` (ресурс-пак `assets/lovisual/textures/...`). Значит миксин на слой игрока (как `Internal33` у evo) не нужен: можно трансформировать вершины на CPU так же, как это делает `WingsRenderer.put()` и `transformPosition()`.
План (режим "модель"):
1. Ассеты. Положить свои/разрешённые файлы в:
- `src/main/resources/assets/lovisual/wings/<key>.geo.json` и `<key>.animation.json`;
- `src/main/resources/assets/lovisual/textures/wings/<key>.png`.
Для отладки можно скопировать (только локально) из:
`/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/wings/{angel,blue_dragon,dark,demon,dragon,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.geo.json`,
`.../cosmetic/wings/{blue_dragon,dark,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.animation.json`,
`.../textures/cosmetic/wings/{angel_white,baby_dragon_white,blue_dragon,dark,demon_black,demon_red,easter,ghoul,pulse,rocker,spider,trident,vulcano,wota}.png`.
2. Новые классы в пакете `wings`:
- `WingModelDef` (record: key, displayName, geoKey, texture Identifier, animKey или procedural id, scale, offsetY, offsetZ). Таблица 2.2 переносится в статический каталог.
- `BedrockGeoParser` (аналог `Rotation2`/`Definition`/`Parent`): читает `minecraft:geometry[0]`, строит дерево костей `Bone{name,parent,pivot,rot,cubes}`. Знаки pivot/rotation как в 2.3. Запекает кубы в массивы позиций и UV (нормировка на `texture_width/height` из description).
- `BedrockAnimation` (аналог `ModelRendererHud.applyAnimation` и `interpolate`): разбор обоих корней (`animations` и плоский), линейные keyframe, `loop`.
- `ProceduralWingAnims` (angel/demon/dragon): три функции `apply(Map<String,BoneState>, float timeSeconds)` с формулами из `Internal29/41/42`.
- Реестр с кэшем, ленивая загрузка через `Minecraft.getInstance().getResourceManager().getResource(Identifier.fromNamespaceAndPath("lovisual", "wings/x.geo.json"))`.
3. Рендер. В `WingsRenderer` добавить `renderModel(renderer, player, tickDelta, bodyYaw, def, style)`:
- Якорь: вызывать ту же цепочку матриц, что `drawWingSide` (translate player, rotY(180-bodyYaw), pre-translate/pitch от `resolvePose`), затем масштаб `def.scale`. По evo: начало координат модели лежит на `1.5 (шея) + offsetY - 0.3` блока над ногами (у режимов с offsetY около -1.05 это почти уровень ног, потому что координаты geo заданы от ног; у angel/dragon/demon с offsetY 0.45 это уровень шеи, кости ниже pivot). Z-сдвиг: `offsetZ + backOffset` (0.078125 при нагруднике, 0.1375 при крыльях и нагруднике) в сторону от спины. Знак по Z и направление взгляда модели подобрать на практике одним тестом (в тестах `WingsRendererTransformTest` есть пример проверки матриц).
- Обход дерева костей: матрица кости = `translate(offset) · translate(pivot) · rotZYX(rot) · scale · translate(-pivot)`, в блоках (деление на 16). Куб рисовать 6 гранями, вершины через `transformPosition` и `batchTextured(WORLD_TEXTURED_DEPTH, texture, DepthMode.MAIN)`; для throughWalls брать `WORLD_TEXTURED` с `DepthMode.NONE`.
- Время анимации: лучше `(player.tickCount + tickDelta) / 20` (у evo wall-clock, одинаково у всех игроков).
- Свет: наш мировой пайплайн не знает lightmap. Умножить цвет вершины на яркость позиции игрока (`level.getBrightness`) или оставить `fullbright` как опцию, чтобы ночью крылья не светились.
- Анимированная текстура wota: смещение V кадра.
- Для режима glide и sneak взять существующую `WingShapes.glidePose/POSE_SNEAK` (повернуть всю модель по pitch, как сейчас), у evo этого нет, это наша добавка.
4. Настройки в `Wings.java`: добавить `ModeValue wingSource` ("Procedural" / "Model") и второй `ModeValue wingModel` (14 ключей из 2.2) с `visibleWhen(..., () -> "Model".equals(wingSource.get()))`. Прежний `wingType` показывать только для "Procedural". Цвет и glow для режима "модель" можно применять как tint (`color`) и дополнительным аддитивным проходом через `WORLD_TEXTURED_ADDITIVE`. Локализация: `assets/lovisual/lang/{en_us,ru_ru}.json` (в проекте есть `DescriptionCoverageTest`, ключи описаний обязательны).
5. Тесты: парсер на мини-geo, проверка знаков pivot/rotation, нормировки UV, интерполяции keyframe (по образцу `WingShapesTest`).
---
## 3. Ножи (knife, модуль Melee Skins)
### 3.1 Что это в evo
Модуль `evovis/module/cosmetics/MeleeSkinsModule.java` (`sz.java`, `@ModuleInfo(name="Melee Skins")`). Подменяет любой меч в руке (`ItemTags.SWORDS`, условие `appliesTo`) на выбранный "скин" (на самом деле полную 3D-модель ножа с анимированными руками). Настройки: `firstPersonSetting` (в своём виде), `thirdPersonSetting` (на теле), `inspectSetting` (клавиша осмотра, `OpenKeySetting`). Выбор скина хранится через `Melee3.equip/applyKey` и сохраняется в профиль косметики (`Cosmetic.persistIfLocal`), в нашем случае достаточно локального конфига.
### 3.2 Ресурсы (источник)
- Каталог моделей/анимаций: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (5.7 МБ).
- `index.json` каталог скинов (33 записи, 16.5 КБ).
- `<key>.geo.json` модель (Bedrock geo, `format_version` 1.12.0) и `<key>.anim.json` анимации (Bedrock animation `format_version` 1.8.0) на 21 геометрию (скины-варианты используют общую геометрию через поле `model`).
- Геометрии: `annihil, bayonet, butterfly, cobra_vanguard, css, executor, karambit, m9, mercy, null_annihil, polaris, push, quenching, shadowkiller, silencefd, skeleton, stiletto, tactical, talon, thieve_sea, treasure_kn`.
- Текстуры: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/textures/cosmetic/melee/<key>.png` (3.5 МБ; размеры от 128 до 2048 px; нормировка UV по `texture_width/height` из geo) и иконки слотов `.../textures/cosmetic/melee/slot/<key>.png` (33 штуки). Служебные `m9.png.ppm` и `m9amethyst.png.ppm` (786 КБ каждый) в мод не нужны.
- Звуки: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/sounds/melee/` (94 ogg, 7.6 МБ), подпапки `annihil, cobra_vanguard, cs2, executor, mercy, polaris, quenching, shadowkiller, silencefd, thieve_sea, treasure_kn` (`val` пустая). Регистрация звуков: `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/sounds.json`, ключи `melee.<папка>.<имя>` (94 события; `melee.cs2.knife_slash1` -> `evolution:melee/cs2/knife_slash1`). Событие в анимации задаётся в `sound_effects` как `{"0.0":{"effect":"melee.cs2.knife_deploy1"}}`.
- Шейдер "holy": `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/shaders/core/melee_holy.vsh` и `melee_holy.fsh` (GLSL 330, шумовое поле fbm, дрейф, градиент; формат вершин ENTITY, Sampler0).
### 3.3 Все скины (из `index.json`)
Базовые (21 геометрия, `key` = имя файла): annihil "Dark Star" (варианты angel/devil, 16 клипов), cobra_vanguard "Cobra Vanguard", executor "Executor", mercy "Mercy", null_annihil "\"Free Dark Star\"", polaris "Desmoulins' Edge", quenching "Dragon's Fang", shadowkiller "Fiery Owl", silencefd "TAC Dagger", thieve_sea "Sea Gleam", treasure_kn "Creed", bayonet "Bayonet", butterfly "Butterfly", css "Classic Knife", karambit "Karambit", m9 "M9 Bayonet", push "Push", skeleton "Skeleton", stiletto "Stiletto", tactical "Huntsman", talon "Talon".
Перекраски (общая геометрия, поле `model` в index.json): bayoauto "Bayonet | Autotronic" (bayonet), buemerald "Butterfly | Emerald" и busnowfall "Butterfly | Snowfall" (butterfly), karobsidian "Karambit | Obsidian" (karambit), m9auto, m9amethyst, m9emerald, m9sapphire, m9holy (m9; `holyBones:["blade"]`), stilettosapphire, stilettott "Tiger tooth", stilettoholy (stiletto; `holyBones:["b"]`).
Итого 33 записи (21 базовая + 12 перекрасок). Текстуры у каждой своя `textures/cosmetic/melee/<key>.png`. Поля записи index.json: `key`, `name`, `model` (общая геометрия, опц.), `clips` (список клипов), `transforms` (`thirdperson_righthand`, `thirdperson_lefthand`, `ground`, `fixed`, `gui`, `head`: `rotation/translation/scale` для разных display-контекстов), `variants` (только annihil: `[{suffix:"",hidden:["angel"]},{suffix:"_angel",hidden:["devil"]}]`, скрытие костей и суффикс клипов), `holyBones` (кости, к которым применяется шейдер holy).
### 3.4 Структура модели и клипов
Кости, общие для всех ножей (пример karambit, m9, bayonet): `root > bone > knife_and_right > <knife> > weapon > ...`, `righthand`, `righthand_pos`, `lefthand`, `lefthand_pos`, `constraint`, а также служебные: `camera`, `view`, `refit_view`, `iron_view`, `idle_view`, `positioning > {ground, thirdperson_hand, fixed}`. Служебные имена, которые читает код (`evovis/internal/Model2.java`): `righthand_pos`, `lefthand_pos` (куда ставить руки игрока), `camera` (покачивание камеры), `idle_view` (якорь вида первого лица), `thirdperson_hand` (якорь в третьем лице).
Клипы (набор варьируется): `draw`, `draw_1` (случайный выбор варианта), `inspect`, `inspect_1`, `melee_1`, `melee_2`, `melee_3`, `static_idle`, `idle`, `run`, `run_start`, `run_end` (не у всех). У annihil суффикс `_angel`. Длительности: draw 0.96 до 1.12 с, melee 1.12 с, inspect от 6 до 13 с.
Формат клипа: `{"loop":true,"animation_length":..,"override_previous_animation":..,"bones":{<bone>:{"rotation":{"0.0":[x,y,z],...},"position":{...},"scale":{...}}},"sound_effects":{"t":{"effect":"melee..."}}}`. Ключи времени: строка секунд (шаг около 0.04 с).
### 3.5 Анимация: осмотр, доставание, удар, бег
Класс состояния: `evovis/internal/Inspect.java` (`defpackage/ahl.java`). Два экземпляра: `Inspect.getInstance()` (главная рука) и `Inspect.offHand()`. Состояние:
- Три трека (`BaseTrack`, `evovis/internal/BaseTrack.java`): `baseTrack` (вечная `static_idle`, режим `Mode6.LOOP`), `mainTrack` (одноразовые: draw, inspect, melee), `movementTrack` (`run_start` > `run` > `run_end`). Режимы `Mode6`: `LOOP`, `ONCE_STOP`, `ONCE_HOLD`.
- Конечный автомат: `MainStateMode.START` > при привязке модели проигрывает `draw` или (50% если есть) `draw_1` > `IDLE`. `MovementStateMode` для бега.
- Осмотр: `onInspect()` (вызывается по клавише из `MeleeSkinsModule.onKeyPressed`) работает только в `IDLE`; с 50% шансом играет `inspect_1` (если клип есть), иначе `inspect`. Переход 0.2 с.
- Атаки: `onAttack(boolean primary)`. ЛКМ чередует `melee_1` и `melee_2` (`PRIMARY_ATTACK_VARIANTS = 2`), ПКМ играет `melee_3`. Переходы 0.2 с (`TRANSITION_ACTION`). Хуки: `evovis/mixin/MeleeInputMixin.java` (`Minecraft.startAttack` HEAD, `Minecraft.startUseItem` RETURN, если не используется оффхенд).
- Бег: только при `player.isSprinting()`; `run_start` (0.2 с) > `run` (цикл по пройденной дистанции, `RUN_CYCLE_DISTANCE = 2.0` блока) > `run_end` (0.3 с). Не у всех ножей есть run-клипы (нет у bayonet/karambit/m9 и др.).
- Смена предмета/слота: `heldChanged` перепривязывает модель (`bind`), случайный вариант (`Variant`) выбирается из `variants`, скрытые кости помечаются `Model2.markHidden`.
- Композиция: `compose()` смешивает статичную позу (static fade) и аддитивную (additive fade) в `float[] composedPose` (на кость 9 float: rot xyz, pos xyz, scale xyz). Время считается по `System.nanoTime`, максимальный шаг 0.25 с.
- Интерполяция клипа: `evovis/internal/Internal68.java` (`CHANNEL_STRIDE=3`, `BONE_STRIDE=9`, Catmull-Rom сплайн; константы `CATMULL_HALF/TWO/FIVE`) и `Tracks.java` (`sample(time, float[] out)` по каналам rotation/position/scale).
- Звуки: `evovis/internal/Melee5.java` (`aia`): события `melee.*` из sounds.json, `play(name)` через `SimpleSoundInstance` в позиции игрока (SoundSource.PLAYERS, volume/pitch 1.0). `BaseTrack` ведёт курсор `sound_effects` (capacity 4) и останавливает звук при смене клипа.
### 3.6 Рендер
Хуки (все в `evovis/mixin/`):
- `MeleeItemInHandMixin` (на `ItemInHandRenderer`): `submitHandsWithItems` HEAD вызывает `Thirdperson.beginHands()`, RETURN вызывает `flushHandItem(...)` (отложенно рисует предмет в левой руке модели ножа); `submitArmWithItem` HEAD cancellable: если `MeleeSkinsModule.rendersFirstPerson(item)` то `Thirdperson.renderFirstPerson(player, hand, matrices, queue, light, retireProgress)` и отмена ванильного рендера; пустую главную руку скрывает `hidesEmptyMainHand`; не-меч в другой руке откладывается `deferHandItem`.
- `MeleePlayerItemInHandLayerMixin` (на `PlayerItemInHandLayer`): `submitArmWithItem` HEAD cancellable для третьего лица: `renderer.getModel().translateToHand(state, arm, matrices)`, `rotation XP -90`, `YP 180`, затем `Thirdperson.renderThirdPerson(...)` (только для своего игрока, `player.getId() == state.id`).
- `MeleeInputMixin` (на `Minecraft`): атака, см. выше.
Класс рендера: `evovis/internal/Thirdperson.java` (`defpackage/...`, на деле рендер первого и третьего лица). Основные приёмы:
- `buildWorld(PrimaryBones, Model2, pose[], hidden[])` считает мировые матрицы костей из позы клипа (`PrimaryBones`: `world`, `combined`, `normals`, `visible`).
- `applyCamera(model, inspect, poseStack)` применяет кость `camera` (покачивание камеры при осмотре и атаке). `applyViewAnchor` якорит модель по кости `idle_view`.
- Руки: `submitArm(...)` рендерит руки игрока (скин через аватар-рендер) в точках `righthand_pos` и `lefthand_pos` модели, `hideHands` скрывает нерелевантные. Левая рука зеркалится при левшах (`handMirrored`, `scale(-1,1,1)`).
- Геометрия ножа: `submitGeometry(...)` отправляет меш каждой кости через `submitCustomGeometry` с `RenderType` entityTranslucent и текстурой скина. Для `holyBones` используется `Melee.holy(texture)` (`evovis/internal/Melee.java`, `ahr.java`): `RenderPipeline "pipeline/melee_holy"` из `evolution:core/melee_holy`, формат вершин `DefaultVertexFormat.ENTITY`, translucent blend, без cull.
- Запечённая модель: `evovis/internal/Model2.java`: массивы `boneNames/boneParents/bonePivots/boneRestRotation`, `boneMeshes[i]` (float[]: на вершину 8 float: pos(3), uv(2), normal(3); `VERTEX_STRIDE = 8`, 4 вершины на грань), `clips: Map<String, Internal68>`, индексы `rightHandBone/leftHandBone/cameraBone/viewAnchorBone`. Бейк `Model2.bake(Definition, key)` из того же `Rotation2`-парсера, что и крылья.
- Загрузка и кэш: `evovis/internal/Melee4.java` (`ahx`): фоновый поток "evo-melee-loader", `ConcurrentHashMap<String, Model2>` кэш, `LOADING` set, вытеснение (`evict`). Модель читается из `/assets/evolution/cosmetic/melee/<geo>.geo.json` и `<geo>.anim.json` (`Melee2.modelResource()/animationResource()`).
- Третье лицо и инвентарь: кость `thirdperson_hand`, в `index.json` блок `transforms.thirdperson_righthand/lefthand` задаёт rotation/translation на скин, `ground/fixed/gui/head` для других display-контекстов (в evo на них отдельные поля не используются в коде рендера кроме third-person, но в данных есть).
### 3.7 Что нужно у нас для отдельного модуля Knife
Новый модуль: `features/module/modules/visuals/items/knife/Knife.java` (`@ModuleInfo(id="knife", displayName="Knife", category=ModuleCategory.VISUALS` или `ACCESSORIES`). Классы:
| Роль | Предлагаемое имя | Прототип в evo |
|---|---|---|
| Модуль и настройки | `Knife` (скин, firstPerson, thirdPerson, inspectKey, scale, offset, звук) | `MeleeSkinsModule` |
| Каталог и дескриптор скина | `KnifeCatalog`, `KnifeSkin` (record: key, geoKey, name, variants, holyBones, thirdPerson transforms, texture, icon) | `Melee3`, `Melee2`, `Variant`, `ThirdPersonLeftHand` |
| Парсер geo и бейк | `BedrockGeoParser` (общий с крыльями), `KnifeModel` | `Rotation2`, `Definition`, `Parent`, `Model2` |
| Клипы и сэмплинг | `KnifeClip`, `KnifeClipTrack` (LOOP/ONCE_STOP/ONCE_HOLD), `KnifeAnimationState` | `Internal68`, `Tracks`, `BaseTrack`, `Mode6`, `Inspect` |
| Рендер первого лица | `KnifeFirstPersonRenderer` | `Thirdperson.renderFirstPerson`, `buildWorld`, `applyCamera`, `applyViewAnchor`, `submitArm`, `submitGeometry` |
| Рендер третьего лица | `KnifeThirdPersonRenderer` | `Thirdperson.renderThirdPerson` |
| Шейдер holy | `KnifeRenderTypes` + шейдер `assets/lovisual/shaders/knife_holy.{vsh,fsh}` (писать по образцу `melee_holy`, регистрация pipeline в `LoVisualRenderPipelines`) | `Melee` + `melee_holy.vsh/fsh` |
| Звуки | `KnifeSounds` + `sounds.json` в `assets/lovisual/` | `Melee5` + `evolution/sounds.json` |
| Загрузка | `KnifeModelCache` (асинхронная, кэш, вытеснение) | `Melee4` |
| Миксины | `KnifeInputMixin` (на `Minecraft`), `KnifeItemInHandMixin` (на `ItemInHandRenderer`), `KnifePlayerItemInHandLayerMixin` (на `PlayerItemInHandLayer`) | `MeleeInputMixin`, `MeleeItemInHandMixin`, `MeleePlayerItemInHandLayerMixin` |
Ресурсы к копированию (источник, для отладки; см. предупреждение о лицензии): весь каталог `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (`index.json`, 21 `.geo.json`, 21 `.anim.json`), все png из `.../textures/cosmetic/melee/` и `.../textures/cosmetic/melee/slot/` (кроме `.ppm`), каталог `.../sounds/melee/` + выбранные ключи из `.../sounds.json`, шейдеры `.../shaders/core/melee_holy.{vsh,fsh}`. Целевые пути у нас: `assets/lovisual/knife/*.json`, `assets/lovisual/textures/knife/*.png`, `assets/lovisual/sounds/knife/*.ogg`.
Что у нас уже есть и можно переиспользовать: `ItemInHandRendererMixin` (`mixins/render/item/ItemInHandRendererMixin.java`, уже перехватывает `submitArmWithItem` и `submitHandsWithItems`-путь), `HoldMyItems`/`ViewModel` (смещения руки), `EquipmentLayerRendererMixin`, текстуры через `Identifier`, `LoVisualRenderPipelines`. Чего нет и надо сделать с нуля: парсер Bedrock geo, система клипов, пайплайн и шейдер holy, миксин на `PlayerItemInHandLayer`, миксин ввода (атака/ПКМ), каталог скинов, UI выбора (иконки слотов).
Порядок работы: 1) общий `BedrockGeoParser` + бейк (он же нужен для крыльев), 2) клипы + `KnifeAnimationState` (draw, static_idle, inspect, melee_1..3 без бега), 3) рендер первого лица без рук (только нож), 4) руки игрока в `righthand_pos`/`lefthand_pos`, 5) третье лицо, 6) звуки, 7) бег, варианты annihil, holy-шейдер, 8) UI и сохранение выбора.
---
## 4. HoldMyItems и Lua ("нода на Lua")
### 4.1 Как это устроено в evo
evo бандлит оригинальный мод HoldMyItems целиком (миксин-конфиг `holdmyitems.mixins.json`, лист `"package":"evovis.mixin"`) и обёртку-модуль `HmiModule` (`evovis/module/render/HmiModule.java`: только флаг `active()`). Движок скриптов: LuaJ 3.0.1 (`/storage/project/jvm/LoVisual/ref/evo/mod/jar/META-INF/jars/luaj-jse-3.0.1.jar`, исходники `evovis`-дерева `org/luaj/vm2`).
Скрипты (источник): `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/minecraft/holdmyitems/`
| Файл | Размер | Назначение |
|---|---|---|
| `hand_pose.lua` | 66 КБ | поза рук "сцены" (экипировка, использование: bow, crossbow, trident, spear, eat/drink, brush, block, подмена свинга для мечей и т.д.) |
| `hand_relative_pose.lua` | 1.6 КБ | поза руки относительно сцены (пустая рука, смена предмета, бросаемое) |
| `item_pose.lua` | 45 КБ | поза самого предмета в руке |
| `item_model.lua` | 20 КБ | анимация частей (квадов) модели предмета: вёдра, лодки, пакеты |
| `hand_pose.lua.rpo`, `item_pose.lua.rpo` | 157 Б | опции Respackopts (`hmi.xOffset/yOffset/zOffset/inspectKeybind`) |
Необязательные "аддоны" подхватываются как `holdmyitems/hand_addon.lua`, `hand_relative_addon.lua`, `item_addon.lua`, `item_model_addon.lua` (со всех ресурс-паков, `getAllResources`).
Ресурсы вокруг скриптов (источник `/storage/project/jvm/LoVisual/ref/evo/mod/jar/assets/minecraft/`): `items/*.json` (35, лодки, вёдра, end_crystal и др.), `models/item/*.json` (48 3D/2D моделей, `eatinganimation/` 17 моделей), `textures/item/` (58), `textures/particle/` (36, пламя и glow для Lua-частиц), `optifine/`.
Классы evo (читаемые имена; оригинал в скобках):
- Загрузчик: `evovis/internal/Holdmyitems.java` (`SimpleSynchronousResourceReloadListener`, id `holdmyitems:lua_animation_loader`). Грузит 4 основных + 4 вида аддонов при перезагрузке ресурсов. Препроцессор `preprocessScript`: строки `global.x = v;` превращаются в `local x = registry:getOrDefault('x', v)` и в конце скрипта добавляется `registry:put('x', x)` (персистентные между кадрами переменные); плейсхолдеры `${name}` заменяются на `0`, если нет мода respackopts.
- Песочница и глобалы: `evovis/internal/Internal82.java` (`LuaScriptManager`): `Globals` с библиотеками Base, Package, Bit32, Table, String, Coroutine, JseMath, `LoadState.install`, `LuaC.install`.
- Кэш скрипта руки/предмета: `evovis/internal/HandScriptCache.java` (аналог `LuaScriptCache`): регистрирует в Lua глобалы `M, I, Texture, Items, Tags, P, Easings, KeyBindManager, S, C, particleManager, swingSpeed, registry, renderAsBlock, translateItem, itemSwingSpeed, animator, useDuration, usingItem, debugger, applyBlockRotation, context` и вызывает `chunk.invoke()` на каждый кадр. При ошибке Lua: тост "HMI Lua Runtime error!" и `canRun=false` до перезагрузки.
- Кэш скрипта модели: `evovis/internal/ItemModelCache.java` (глобал `data` вместо `context`), `executeModel`.
- Контекст кадра: `Context` (поля `mainHand, hand, bl (правая ли рука), swingProgress, mainHandSwingProgress, offHandSwingProgress, equipProgress, matrices (PoseStack), item, player, deltaTime, mainHandSwitchEvent, offHandSwitchEvent, swingMHand, swingOHand, interact, blockBreaking, particles`).
- Опасная часть: `evovis/mixin/JavaMethodMixin.java` патчит `org.luaj.vm2.lib.jse.JavaMethod.invokeMethod` и возвращает NIL для любого Java-метода без аннотации `@Safe` (кроме `getOrDefault`/`put`): так защищают от того, что Lua дёргает произвольные Java-методы.
- Обёртки API (Lua-видимые): `MInstance` (`M:`: moveX/Y/Z, rotateX/Y/Z (с pivot), scale, translate, shear, push/pop, sin/cos/abs/round/floor/ceil/pow/clamp/lerp), `IInstance` (`I:`: isOf, isIn, isEmpty, isBlock, isThrowable, isLantern, isChargedCrossbow, isEnchanted, getUseAction, getName, getAttackDamage, getSpearData, setSwingSpeed, setRenderAsBlock, setTranslate, shouldRenderAsBlock, shouldTranslateItem, isCustomTranslate, setMainStack/setOffStack, getComponents, copyAppearanceComponents, getDefaultStack), `PInstance` (`P:`: getSpeed, getXSpeed/YSpeed/ZSpeed, getYaw/Pitch, getX/Y/Z, getAge, getHealth, isOnGround, isSneaking, isSwimming, isCrawling, isClimbing, isUsingItem, isUsingRiptide, isUsingSpyglass, isTouchingWater, isSubmergedInWater, hasVehicle, isItemCoolingDown, getActiveHand, getMainItem, getOffhandItem, getSwingCount, getBlockBelow/Above/Standing), `EasingsInstance` (30 easing функций: easeInOutBack, easeOutElastic, easeOutBounce, cubicEase и др.), `JsItemsInstance` (`Items:get("minecraft:...")`), `JsTagsInstance` (`Tags:getVanillaTag/getFabricTag`), `KeyBindManagerInstance` (`isKeyPressed`), `SInstance` (`S:playSound`), `CInstance` (`C:setCamRot/setCamPos`, камера), `TextureInstance`, `ParticleManagerInstance` (`particleManager:addParticle(...)` с Lua-колбэком на тик; сама система частиц `Particle`, `ParticleManager`, рендер-типы в мире и в руке).
- Анимация частей модели предмета: `ModelPartAnimator` (`animator:rotateX/Y/Z(from, to, deg, pivotX, pivotY, pivotZ)`, `moveX/Y/Z`, `scale` по диапазону индексов частей `from..to`, применяется при рендере квадов через `ItemRendererMixin`/`BlockRenderManagerMixin`).
Чистый, не обфусцированный референс тех же классов: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/com/holdmylua/source/`:
`lua_runtime/{LuaScriptManager,LuaScriptCache,ModelScriptCache,ScriptHolder}.java`, `lua_runtime/resource_controller/LuaAnimationResourceLoader.java`, `global/{LuaContext,GlobalsStorage,DispatcherStorage}.java`, `global/item_model/*`, `scripting/script_wrappers/{M,P,IInstance,Easings,C,S,JSItems,JSTags}.java`, `scripting/custom_api/*`, `model/{ModelPartAnimator,PoseX/Y/Z,RotationX/Y/Z,ScalePose}.java`, `patricles/*` (Particle 450 строк, ParticleManager), `annotation/Safe.java`, `access/*` (аксессоры), `SwordAttacks.java`, `data_structures/SpearData.java`, `compat/IrisCompat.java`.
Миксины: `/storage/project/jvm/LoVisual/ref/clients/mercury-src/fun/mercury/mixin/hmi/*` (BlockRenderManagerMixin, HandRenderTypeMixin, HmiCameraMixin, HmiGameRendererMixin (deltaTime по GLFW, не более 0.05), HmiLivingEntityMixin (счётчики свинга), HmiMinecraftClientMixin (doAttack, doItemUse), ItemRendererMixin, ItemRenderStateMixin, ItemStackMixin (поля transform, swingSpeed, renderAsBlock), JavaMethodMixin, UseDurationMixin, UsingItemMixin) и главный `HeldItemRendererMixin.java` (905 строк; точки вызова скриптов: `hmi$itemPose` > `item_pose`, `hmi$mainHandPose` > `hand_relative_pose`, `hmi$scenePoseMain` > `hand_pose`; `hmi$applyArmMatrices` копирует ванильное построение руки; `wrapApplyEquipOffset`/`wrapSwingArm` отключают ванильную экипировку и свинг; `hmi$renderOverhaul` переопределяет `renderItem`).
Другие Lua-системы в ref (для идей "нод"/скриптов модулей, не руки):
- `/storage/project/jvm/LoVisual/ref/clients/sacura-1.21.4/src/main/java/su/sacura/util/impl/lua/`: `LuaManager` (папка `<run>/sacura/lua`, вызывает глобальные `onEnable/onDisable/onUpdate/onRender` у каждого скрипта-модуля), `LuaScript` (скрипт как `Module`), API `ClientApi, GlobalApi, ModulesApi, PlayerApi, Render3DApi, RenderApi, SettingsApi, Vec2`, `render/LuaFontManager`. Зависимость: `org.luaj:luaj-jse:3.0.1` (в `build.gradle` как `include`).
- `/storage/project/jvm/LoVisual/ref/clients/newcode-1.21/` (`FiguraAvatarInstaller`, `figura_avatars/*/script.lua`): Figura-аватары на Lua (`test_luaj.gradle`).
- `/storage/project/jvm/LoVisual/ref/clients/mercury-src/` (HoldMyLua, выше).
- `/storage/project/jvm/LoVisual/ref/README.md` упоминает `hmi` (HoldMyItems event API) и `mercury-src` (HoldMyLua), но каталога `ref/clients/hmi` на диске сейчас нет; в `mod/TODO.md` строки 546-560 описан разбор HMI.zip в `/tmp/lv_refs/hmi/` (Delta HMI, обёртка над сторонним `holdmyitems-5.1.1+26.2.jar`).
### 4.2 Что есть у нас
Только чистая Java-динамика, без Lua и без скриптов:
- `features/module/modules/visuals/items/held/HoldMyItems.java` (`id="holdmyitems"`, displayName "Item Holder"): настройки `sway_intensity`, `ease_strength`, `offhand`, `walk_bob`/`bob_strength`, `use_drift`/`drift_strength`, `join_appear`, `grip_enabled` + 6 слайдеров grip (`gripExtend/Slide/Out/Forward/Tilt/Turn/Roll`). Метод `onFrame` вызывает `HandAnimState.beginFrame`. Метод `shapeEquipProgress`.
- `util/animation/hand/HandDynamics.java` (применяет позу в `submitArmWithItem` HEAD через `pushPose`), `HandPoseMath` (sway, bob, drift, joinDrop, grip), `HandAnimState`, `SwayState`, `BobMath`, `HeldAlongFist`.
- `util/animation/EquipProgressComposer.java` + `EaseCurves` (кривая equip).
- `mixins/render/item/ItemInHandRendererMixin.java`: инъекции `submitArmWithItem` HEAD/RETURN (динамика руки), HEAD (ViewModel offsets), `@ModifyVariable` equipProgress (ordinal 3), `renderItem` (мини-предметы), `swingArm` (кастомные свинги ViewModel `Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust` из `visuals/camera/swing/*`).
- Родственные: `visuals/camera/ViewModel.java`, `visuals/items/held/AlwaysVisibleHand.java`, `mixins/render/item/ItemInHandAlwaysVisibleMixin.java`.
Решение по Lua в `mod/TODO.md` (строки 546-558): полный HMI не портировали из-за правила "без стороннего jar в сборке" и KISS; взята только идея grip. Скриптового движка для рук нет. В проекте есть только Javet (V8) для UI/HUD-скриптов: `render/engine/renderer/ui/runtime/script/engine/{UiScriptEngine,JavetUiScriptEngine,UiScriptEngineProvider}.java` (подключение в `build.gradle`: `bundledLibrary "com.caoccao.javet:javet:5.0.9"`, строка 186). Lua/LuaJ в проекте отсутствует (в зависимостях нет `luaj`).
### 4.3 Чего не хватает до полного функционала HMI (список)
Движок и инфраструктура:
1. Зависимость LuaJ: `bundledLibrary 'org.luaj:luaj-jse:3.0.1'` в `mod/build.gradle` рядом со строкой Javet. Версия та же, что у evo, mercury и sacura. Лицензия LuaJ (MIT) допускает бандл, но это "сторонняя библиотека в сборке", как Javet, а не сторонний мод.
2. Пакет `features/module/modules/visuals/items/held/lua/`: `LuaHandRuntime` (общие `Globals`, как `Internal82`), `HandScript` (аналог `HandScriptCache`: 3 основных скрипта руки/предмета + аддоны), `ItemModelScript` (аналог `ItemModelCache`), `HandScriptContext` (поля из `Context`), `ScriptHolder`, `LuaScriptPreprocessor` (регэкспы `global.x = v;` > `registry`, `${name}` > `0`), `HmiResourceReloadListener` (загрузка `holdmyitems/*.lua` из ресурс-паков, плюс из `config/lovisual/holdmyitems/` для пользовательских скриптов).
3. Песочница. Evo и mercury используют миксин на `JavaMethod` + `@Safe`. Лучше без миксина на сторонний класс: не отдавать Lua `CoerceJavaToLua.coerce(javaObject)` (даёт доступ ко всем public-методам, включая `getClass()`), а собирать `LuaTable` с `LibFunction`-обёртками только нужных методов (M, I, P, Easings, Items, Tags, S, C, KeyBindManager, particleManager, registry). `Globals` без `JseBaseLib.loadfile/dofile` и без `luajava`, `io`, `os`. Это закрывает вопрос безопасности скриптов из чужих ресурс-паков.
4. Реестр персистентных переменных `registry` (HashMap) со сбросом при перезагрузке ресурсов.
5. Дельта времени кадра: по GLFW (`HmiGameRendererMixin`: `min(0.05, dt)`, 0 при паузе). У нас есть `render/helpers/util/TickDelta`.
6. Тосты/лог ошибок Lua (`canRun=false` до reload).
Хуки рендера (нашего `ItemInHandRendererMixin` для этого мало):
7. Подмена всей цепочки руки: обернуть/перенаправить вызов построения руки и предмета (аналог `hmi$renderOverhaul`, `wrapApplyEquipOffset`, `wrapSwingArm`), чтобы Lua управлял `equipProgress` и свингом вместо ванили. Сейчас у нас только `ModifyVariable` equipProgress, `swingArm` cancel для ViewModel и pushPose/popPose.
8. Раздельные фазы скриптов: `hand_pose` (сцена), `hand_relative_pose` (рука относительно), `item_pose` (предмет), плюс вызов `item_model` при рендере модели.
9. Счётчики свинга и события (`mainHandSwingProgress/offHandSwingProgress`, `swingMHand/swingOHand`, `interact`, `blockBreaking`, `mainHandSwitchEvent/offHandSwitchEvent`): миксины `HmiLivingEntityMixin`, `HmiMinecraftClientMixin` (doAttack, doItemUse), `getSwingCount`.
10. Рендер обеих рук всегда (`HandRenderTypeMixin`: `renderMainHand/renderOffHand = true`).
11. Подмена параметров ItemStack на лету: `ItemStackMixin` (поля transform, swingSpeed, renderAsBlock) и `setMainStack/setOffStack`, `copyAppearanceComponents`.
12. Подмена свойств item-моделей `use_duration` и `using_item` (`UseDurationMixin`, `UsingItemMixin`) для анимаций еды/лука/арбалета.
13. Аниматор частей модели (`ModelPartAnimator` + хуки на рендер квадов `ItemRendererMixin`, `BlockRenderManagerMixin`, `ItemRenderStateMixin` сброс диспетчера) для `item_model.lua` (вёдра с рыбой, лодки, пакеты).
14. Камера из Lua (`C:setCamRot/setCamPos`, `HmiCameraMixin`) для crawl/climb/riptide.
15. Lua-частицы: `Particle`, `ParticleManager`, рендер-типы (мир/рука), `particleManager:addParticle` с Lua-колбэком (пламя, glow).
16. Привязка клавиши осмотра (`KeyBindManager`, `hmi.inspectKeybind`, `S:playSound`). У нас есть настройки кнопки в ClickGUI (`OpenKeySetting`-аналог), надо пробросить в Lua.
Контент:
17. Сами скрипты: `hand_pose.lua`, `hand_relative_pose.lua`, `item_pose.lua`, `item_model.lua` (источник: каталог из 4.1). Для релиза свои или с разрешением автора HMI/evo. Скрипты завязаны на ресурсы `assets/minecraft/items/*.json`, `models/item/*` (3D-вёдра, лодки, eatinganimation), `textures/particle/*`.
18. Настройки в ClickGUI: выбор набора скриптов, переключатели per-feature (inspect, eating, bow, crossbow, trident, spear, sword attacks), горячая перезагрузка скриптов (`.reload`-команда или кнопка).
19. Тесты: препроцессор `global.x`, песочница (скрипт не вызывает `os/io`, не достаёт `getClass`), математика `M:` и `Easings:` (по образцу `HandPoseMath`-тестов).
Что у нас лучше evo: готовые Java-режимы (sway/bob/drift/grip), настраиваемые слайдеры в ClickGUI, ViewModel-свинги, нет зависимости от Lua-интерпретатора и риска чужих скриптов. Рекомендация: сначала добавить пункты 1-8 как режим "Scripted" внутри существующего `HoldMyItems` (переключатель `Dynamics (Java)` / `Scripts (Lua)`), собственные короткие примеры скриптов, и лишь затем наращивать 9-16.
### 4.4 Минимальный план "нода на Lua" (если нужна именно скриптуемая нода)
Идея аналогична sacura `LuaManager`/`LuaScript`: Lua-файл = модуль/узел, который объявляет `onEnable`, `onDisable`, `onUpdate`, `onRender`. Минимум:
1. `LuaScriptManager` + `LuaScript` (по образцу `sacura/util/impl/lua`), папка `<run>/lovisual/lua`, регистрация скрипта как `Module` в нашем реестре модулей (`features/module/lifecycle/Modules`).
2. Сначала только безопасные API: `Player`, `Render3D` (линии, квады через `Renderer3D`), `Settings` (создание num/bool/mode значений как у `Module.num/bool/modeSetting`), `Client` (chat print).
3. Для hold-items узла: отдельный тип скрипта `held`, вызываемый из `ItemInHandRendererMixin` с `HandScriptContext`, `matrices`, глобалами `M, I, P, Easings` из 4.1.
Альтернатива Lua: у нас уже есть Javet/V8 (`UiScriptEngine`). Если совместимость с готовыми HMI-паками не нужна, то скрипты рук можно писать на JS и не тянуть второй интерпретатор. Но экосистема HMI (готовые `hand_pose.lua` и т.д.) только на Lua, поэтому для "полного функционала HMI" нужен именно LuaJ.
---
## 5. Итоговый приоритет работ
1. Общий `BedrockGeoParser` + бейк + сэмплер анимации (нужен и крыльям, и ножам). Затем режим "модель" в Wings (раздел 2.4): ближайший заметный результат при умеренной сложности (M).
2. Модуль Knife (раздел 3.7): самая эффектная фича, сложность L, строить этапами.
3. Быстрые модули: TNTTimer, AutoSprint, TotemCounter, PlaceAnimation, EnchantGlint, ArmorColor, GodRays, ProjectileTrails.
4. DeathEffects, Blizzard, ElytraTrails.
5. LuaJ-движок и скриптовый режим HoldMyItems (раздел 4.3, пункты 1-8), потом остальное.
6. Лицензионный вопрос по ассетам evo решить до начала копирования любых файлов.

View file

@ -18,3 +18,5 @@ xaero_minimap_version=fabric-26.2-26.1.3
xaero_worldmap_version=fabric-26.2-1.41.1
viafabricplus_version=4.6.0
entityculling_version=1.10.5
voicechat_api_version=2.6.24
mixinextras_version=0.5.5

View file

@ -0,0 +1,61 @@
package dev.loki.lovisual.compat.voicechat;
import de.maxhenkel.voicechat.api.VoicechatApi;
import de.maxhenkel.voicechat.api.VoicechatClientApi;
import de.maxhenkel.voicechat.api.VoicechatPlugin;
import de.maxhenkel.voicechat.api.events.ClientReceiveSoundEvent;
import de.maxhenkel.voicechat.api.events.ClientSoundEvent;
import de.maxhenkel.voicechat.api.events.ClientVoicechatConnectionEvent;
import de.maxhenkel.voicechat.api.events.EventRegistration;
import de.maxhenkel.voicechat.api.events.MicrophoneMuteEvent;
/**
* Simple Voice Chat plugin entrypoint ({@code "voicechat"} in fabric.mod.json). Only loaded
* by the voice chat mod itself, so nothing here is ever resolved when it is not installed.
*/
public final class LoVisualVoicechatPlugin implements VoicechatPlugin {
@Override
public String getPluginId() {
return "lovisual";
}
@Override
public void initialize(VoicechatApi api) {
if (api instanceof VoicechatClientApi client) {
VoiceChatBridge.attach(new ClientProbe(client));
}
}
@Override
public void registerEvents(EventRegistration registration) {
VoiceState state = VoiceChatBridge.rawState();
registration.registerEvent(ClientReceiveSoundEvent.EntitySound.class,
e -> state.markSpeaking(e.getEntityId(), e.isWhispering()));
registration.registerEvent(ClientSoundEvent.class, e -> state.markSelfSpeaking(e.isWhispering()));
registration.registerEvent(MicrophoneMuteEvent.class, e -> state.setMuted(e.isDisabled()));
registration.registerEvent(ClientVoicechatConnectionEvent.class, e -> state.setConnected(e.isConnected()));
}
private record ClientProbe(VoicechatClientApi api) implements VoiceChatBridge.SelfProbe {
@Override
public boolean muted() {
return api.isMuted();
}
@Override
public boolean disabled() {
return api.isDisabled();
}
@Override
public boolean disconnected() {
return api.isDisconnected();
}
@Override
public boolean pushToTalk() {
return api.isPushToTalkKeyPressed();
}
}
}

View file

@ -0,0 +1,52 @@
package dev.loki.lovisual.compat.voicechat;
import net.fabricmc.loader.api.FabricLoader;
/**
* Safe entry point to Simple Voice Chat data. Never touches the voice chat API itself, so
* any class may use it whether or not the mod is installed; the plugin feeds it when present.
*/
public final class VoiceChatBridge {
public static final String MOD_ID = "voicechat";
private static final VoiceState STATE = new VoiceState(System::currentTimeMillis);
private static volatile SelfProbe probe;
private VoiceChatBridge() {
}
/** Live reads of the local microphone, implemented by the plugin on top of the voice chat API. */
public interface SelfProbe {
boolean muted();
boolean disabled();
boolean disconnected();
boolean pushToTalk();
}
public static boolean isInstalled() {
return FabricLoader.getInstance().isModLoaded(MOD_ID);
}
/** Current state with the local microphone flags refreshed; cheap enough to call every frame. */
public static VoiceState state() {
SelfProbe p = probe;
if (p != null) {
STATE.setMuted(p.muted());
STATE.setDisabled(p.disabled());
STATE.setPushToTalk(p.pushToTalk());
STATE.setConnected(!p.disconnected());
}
return STATE;
}
static VoiceState rawState() {
return STATE;
}
static void attach(SelfProbe selfProbe) {
probe = selfProbe;
}
}

View file

@ -0,0 +1,120 @@
package dev.loki.lovisual.compat.voicechat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.LongSupplier;
/**
* Thread-safe snapshot of who is talking in Simple Voice Chat. Written from the voice
* threads by the plugin, read from the render thread by the module and the HUD. Holds no
* Simple Voice Chat or Minecraft types, so it stays testable and safe to load without the mod.
*/
public final class VoiceState {
/** State of the local microphone, ordered by display priority. */
public enum MicStatus { DISCONNECTED, DISABLED, MUTED, WHISPERING, TALKING, PUSH_TO_TALK, IDLE }
/** One remote speaker; {@code lastMs} is when audio from them was last received. */
public record Speaker(UUID id, boolean whisper, long lastMs) { }
private record Voice(boolean whisper, long lastMs) { }
private static final Comparator<Speaker> NEWEST_FIRST = Comparator.comparingLong(Speaker::lastMs).reversed();
private final LongSupplier clock;
private final Map<UUID, Voice> speakers = new ConcurrentHashMap<>();
private volatile Voice self = new Voice(false, Long.MIN_VALUE);
private volatile boolean connected;
private volatile boolean muted;
private volatile boolean disabled;
private volatile boolean pushToTalk;
public VoiceState(LongSupplier clock) {
this.clock = clock;
}
public void markSpeaking(UUID id, boolean whisper) {
if (id != null) speakers.put(id, new Voice(whisper, clock.getAsLong()));
}
public void markSelfSpeaking(boolean whisper) {
self = new Voice(whisper, clock.getAsLong());
}
public void setConnected(boolean value) {
connected = value;
if (!value) speakers.clear();
}
public void setMuted(boolean value) { muted = value; }
public void setDisabled(boolean value) { disabled = value; }
public void setPushToTalk(boolean value) { pushToTalk = value; }
public boolean isConnected() { return connected; }
public void clear() {
speakers.clear();
self = new Voice(false, Long.MIN_VALUE);
}
public boolean isSpeaking(UUID id, long windowMs) {
Voice v = speakers.get(id);
return v != null && age(v) <= windowMs;
}
public boolean isWhispering(UUID id, long windowMs) {
Voice v = speakers.get(id);
return v != null && v.whisper() && age(v) <= windowMs;
}
/** Fade factor: 1 while audio is arriving, falling linearly to 0 over {@code fadeMs} after it stops. */
public float level(UUID id, long fadeMs) {
Voice v = speakers.get(id);
return v == null ? 0f : fade(age(v), fadeMs);
}
public float selfLevel(long fadeMs) {
return fade(age(self), fadeMs);
}
public boolean isSelfWhispering(long windowMs) {
Voice v = self;
return v.whisper() && age(v) <= windowMs;
}
/** Remote speakers heard within {@code windowMs}, newest first; stale entries are dropped. */
public List<Speaker> speakers(long windowMs, int max) {
List<Speaker> out = new ArrayList<>();
speakers.entrySet().removeIf(e -> age(e.getValue()) > Math.max(windowMs * 4, 10_000L));
for (Map.Entry<UUID, Voice> e : speakers.entrySet()) {
if (age(e.getValue()) <= windowMs) {
out.add(new Speaker(e.getKey(), e.getValue().whisper(), e.getValue().lastMs()));
}
}
out.sort(NEWEST_FIRST);
return out.size() > max ? new ArrayList<>(out.subList(0, Math.max(0, max))) : out;
}
public MicStatus micStatus(long windowMs) {
if (!connected) return MicStatus.DISCONNECTED;
if (disabled) return MicStatus.DISABLED;
if (muted) return MicStatus.MUTED;
if (age(self) <= windowMs) return self.whisper() ? MicStatus.WHISPERING : MicStatus.TALKING;
return pushToTalk ? MicStatus.PUSH_TO_TALK : MicStatus.IDLE;
}
private long age(Voice v) {
return v.lastMs() == Long.MIN_VALUE ? Long.MAX_VALUE : clock.getAsLong() - v.lastMs();
}
private static float fade(long ageMs, long fadeMs) {
if (ageMs == Long.MAX_VALUE || ageMs > fadeMs) return 0f;
return fadeMs <= 0 ? 1f : 1f - (float) ageMs / fadeMs;
}
}

View file

@ -35,6 +35,10 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
private final ModeValue menuBg =
new ModeValue("menuBg", "waves", "off", "waves", "aurora");
//@CFGComment("Main menu layout: orbs / hero")
private final ModeValue menuLayout =
new ModeValue("menuLayout", "orbs", "orbs", "hero");
//@CFGComment("Use ClickGUI theme colors for menu background shader")
private final BooleanValue menuBgUseTheme =
new BooleanValue("menuBgUseTheme", true);
@ -147,6 +151,10 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
return menuBg.get();
}
public String getMenuLayout() {
return menuLayout.get();
}
public boolean isMenuBackgroundUseTheme() {
return menuBgUseTheme.get();
}
@ -194,9 +202,16 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
List<SettingDef> list = new ArrayList<>();
list.add(SettingDef.mode(msaa3d)
.unavailableWhen(IrisRuntime::isShaderpackRendererActive, MainConfig::irisMsaaReason));
return list;
}
/** Everything about the main menu in one group: layout, background, theme colors, clock. */
public List<SettingDef> getMainMenuSettingDefs() {
List<SettingDef> list = new ArrayList<>();
list.add(SettingDef.mode(menuLayout));
list.add(SettingDef.mode(menuBg));
list.add(SettingDef.bool(menuClockShowSeconds));
list.add(SettingDef.bool(menuBgUseTheme));
list.add(SettingDef.bool(menuBgUseTheme).visibleWhen(() -> !"off".equalsIgnoreCase(menuBg.get())));
list.add(SettingDef.bool(menuClockShowSeconds).visibleWhen(() -> !"hero".equalsIgnoreCase(menuLayout.get())));
return list;
}
@ -274,6 +289,7 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
List<ConfigValue<?>> list = new ArrayList<>();
list.add(debug);
list.add(menuBg);
list.add(menuLayout);
list.add(menuClockShowSeconds);
list.add(menuBgUseTheme);
list.add(msaa3d);

View file

@ -3,12 +3,14 @@ package dev.loki.lovisual.features.command.impl.world;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.ClientCommand;
import dev.loki.lovisual.features.command.impl.CommandUtils;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.misc.Balancer;
import dev.loki.lovisual.util.mctiers.MCTiersCache;
import dev.loki.lovisual.util.mctiers.MCTiersTier;
import java.util.List;
import java.util.Locale;
/**
@ -23,6 +25,19 @@ import java.util.Locale;
descriptionKey = "command.tier.description"
)
public final class TierCommand implements ClientCommand {
private static final List<String> MODES = List.of("vanilla", "uhc", "pot", "nethop", "smp", "sword", "axe", "mace");
@Override
public List<String> suggest(CommandContext ctx, int argIndex, String token) {
String lower = token == null ? "" : token.toLowerCase(Locale.ROOT);
if (argIndex == 1) {
return CommandUtils.suggestPlayers(ctx.mc(), token);
}
if (argIndex == 2) {
return MODES.stream().filter(mode -> mode.startsWith(lower)).toList();
}
return List.of();
}
@Override
public boolean execute(CommandContext ctx) {

View file

@ -0,0 +1,38 @@
package dev.loki.lovisual.features.command.suggest;
import com.mojang.brigadier.context.StringRange;
import com.mojang.brigadier.suggestion.Suggestion;
import com.mojang.brigadier.suggestion.Suggestions;
import java.util.ArrayList;
import java.util.List;
/**
* Splits raw command suggestions into pickable completions and a usage-hint placeholder
* (an entry like "%toggle &lt;module&gt;"), so the vanilla chat popup lists only real choices
* and shows the hint as ghost text instead of inserting it.
*
* @param suggestions pickable completions, all sharing the first one's range; null when none
* @param ghostHint placeholder such as "&lt;module&gt;" to show after the cursor, or null
*/
public record SuggestionPlan(Suggestions suggestions, String ghostHint) {
public static SuggestionPlan of(List<Suggestion> raw) {
List<Suggestion> real = new ArrayList<>();
String hint = null;
if (raw != null) {
for (Suggestion s : raw) {
String text = s.getText();
int open = text.lastIndexOf('<');
if (open >= 0 && text.endsWith(">")) {
if (hint == null) hint = text.substring(open);
} else {
real.add(s);
}
}
}
if (real.isEmpty()) return new SuggestionPlan(null, hint);
StringRange range = real.get(0).getRange();
return new SuggestionPlan(new Suggestions(range, real), hint);
}
}

View file

@ -297,7 +297,8 @@ public final class ModulesMenuPanel {
settingsSmoothScroll = 0.0f;
}
record ModuleHit(String id, float x, float y, float w, float h, boolean hasSettings, boolean toggleable) {
record ModuleHit(String id, String title, String description, float x, float y, float w, float h,
boolean hasSettings, boolean toggleable) {
}
record SettingHit(Setting setting, float x, float y, float w, float h) {

View file

@ -30,7 +30,7 @@ public enum ModulesMenuResolver {
out.add(new ModuleComponent.CardEntry(
module.name(),
module.getDisplayName(),
locked ? I18n.get("lovisual.policy.locked", ServerPolicyState.reason()) : "",
locked ? I18n.get("lovisual.policy.locked", ServerPolicyState.reason()) : module.getDescription(),
bind == null ? "" : bind,
!module.getSettings().isEmpty(),
module.isEnabled(),

View file

@ -31,6 +31,7 @@ public final class ModulesMenuScreen {
float scale = 1.0f; float screenAnim = 0.0f; boolean openTarget = false;
float searchAnim = 0.0f; float searchX; float searchY; float searchW; float searchH;
float searchBtnX; float searchBtnY; float searchBtnW; float searchBtnH; float searchBtnHoverAnim;
float descAnim; String descTitle; String descText;
public ModulesMenuScreen() {
for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal()));
@ -80,6 +81,7 @@ public final class ModulesMenuScreen {
}
ModulesMenuChromeRenderer.renderSearchButton(this, mouseX, mouseY, screenAnim, scale);
ModulesMenuChromeRenderer.renderSearch(this, screenAnim, scale);
ModulesMenuDescriptionRenderer.render(this, mouseX, mouseY, screenAnim, scale);
ModulesMenuChromeRenderer.renderHints(this, screenAnim, scale);
ClickGuiRenderer.flushRenderer();
}

View file

@ -0,0 +1,45 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model;
import java.util.ArrayList;
import java.util.List;
import java.util.function.ToDoubleFunction;
/** Word wrapping for the hover description plaque, independent of any renderer. */
public final class DescriptionLines {
private static final String ELLIPSIS = "...";
private DescriptionLines() {
}
/**
* Greedy word wrap; a single word wider than {@code maxWidth} gets its own line, and when more
* than {@code maxLines} are needed the last line is cut with an ellipsis.
*/
public static List<String> wrap(String text, double maxWidth, ToDoubleFunction<String> measure, int maxLines) {
List<String> lines = new ArrayList<>();
if (text == null || text.isBlank() || maxLines < 1) return lines;
StringBuilder line = new StringBuilder();
for (String word : text.trim().split("\\s+")) {
String candidate = line.isEmpty() ? word : line + " " + word;
if (!line.isEmpty() && measure.applyAsDouble(candidate) > maxWidth) {
lines.add(line.toString());
line.setLength(0);
candidate = word;
}
line.setLength(0);
line.append(candidate);
}
if (!line.isEmpty()) lines.add(line.toString());
return lines.size() <= maxLines ? lines : truncate(lines, maxLines, maxWidth, measure);
}
private static List<String> truncate(List<String> lines, int maxLines, double maxWidth, ToDoubleFunction<String> measure) {
List<String> out = new ArrayList<>(lines.subList(0, maxLines));
String last = out.get(maxLines - 1);
while (last.length() > 1 && measure.applyAsDouble(last + ELLIPSIS) > maxWidth) {
last = last.substring(0, last.length() - 1);
}
out.set(maxLines - 1, last + ELLIPSIS);
return out;
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.DescriptionLines;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import java.util.List;
/**
* Hover plaque above the module columns: shows the description of the module row under the
* cursor and fades out when nothing is hovered.
*/
final class ModulesMenuDescriptionRenderer {
private static final int MAX_LINES = 3;
private ModulesMenuDescriptionRenderer() {
}
static void render(ModulesMenuScreen screen, float mouseX, float mouseY, float screenAnim, float scale) {
ModulesMenuPanel.ModuleHit hit = hoveredHit(screen, mouseX, mouseY);
boolean show = hit != null && !hit.description().isBlank();
if (show) {
screen.descTitle = hit.title();
screen.descText = hit.description();
}
screen.descAnim = AnimationUtility.snap(
AnimationUtility.approach(screen.descAnim, show ? 1.0f : 0.0f, AnimationUtility.deltaTime(), 9.0f),
show ? 1.0f : 0.0f, 0.01f);
float alpha = ModulesMenuLayout.easeOutCubic(screenAnim) * ModulesMenuLayout.easeOutCubic(screen.descAnim);
if (alpha <= 0.001f || screen.descText == null) return;
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);
List<String> 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 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 radius = 8.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;
int bodyColor = ModulesMenuLayout.withAlpha(ModulesMenuStyle.textMuted(), alpha);
for (String line : lines) {
ClickGuiRenderer.drawText(bodyFont, line, x + pad, ty, bodySize, bodyColor, false);
ty += lineH;
}
}
private static ModulesMenuPanel.ModuleHit hoveredHit(ModulesMenuScreen screen, float mx, float my) {
if (!ModulesMenuLayout.isInteractive(screen.screenAnim, screen.openTarget)) return null;
for (ModulesMenuPanel panel : screen.panels) {
if (panel.selected != null || !ModulesMenuLayout.inside(mx, my, panel.x, panel.y, panel.w, panel.h)) continue;
for (ModulesMenuPanel.ModuleHit hit : panel.hits) {
if (ModulesMenuLayout.inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) return hit;
}
}
return null;
}
private static float panelsTop(ModulesMenuScreen screen) {
float top = Float.MAX_VALUE;
for (ModulesMenuPanel panel : screen.panels) top = Math.min(top, panel.y);
return top == Float.MAX_VALUE ? screen.areaY : top;
}
}

View file

@ -87,7 +87,8 @@ final class ModulesMenuModuleRenderer {
Renderer2D.COLOR.svg("check", checkX, checkY, check, check,
SvgRenderOptions.overrideColor(ModulesMenuLayout.withAlpha(ModulesMenuStyle.text(), alpha * (0.10f + 0.90f * enabled))));
}
panel.hits.add(new ModulesMenuPanel.ModuleHit(entry.getId(), x, y, rowW, h, entry.hasSettings(), entry.toggleable()));
panel.hits.add(new ModulesMenuPanel.ModuleHit(entry.getId(), entry.title(), entry.description() == null ? "" : entry.description(),
x, y, rowW, h, entry.hasSettings(), entry.toggleable()));
}
static void renderModuleRowHover(ModulesMenuPanel panel, float x, float y, float w, float h,

View file

@ -1,5 +1,6 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings;
import net.minecraft.client.resources.language.I18n;
import dev.loki.lovisual.config.io.ConfigSerializer;
import dev.loki.lovisual.config.definition.SettingDef;
import dev.loki.lovisual.config.definition.SettingOwner;
@ -64,7 +65,7 @@ public enum MenuSettingsResolver {
out.add(new ModuleComponent.CardEntry(
widget.getId(),
widget.getTitle(),
"",
widget.getDescription(),
"",
!widget.getSettingDefs().isEmpty(),
widget.isEnabled(),
@ -147,7 +148,7 @@ public enum MenuSettingsResolver {
return new ModuleComponent.CardEntry(
staticHud.getId(),
title,
"",
staticHud.getDescription(),
"",
!staticHud.getSettingDefs().isEmpty(),
staticHud.isEnabled(),
@ -156,7 +157,7 @@ public enum MenuSettingsResolver {
}
private static ModuleComponent.CardEntry uiReadOnlyCard(String id, String title) {
return new ModuleComponent.CardEntry(id, title, "", "", true, false, false);
return new ModuleComponent.CardEntry(id, title, I18n.get("hud." + id + ".description"), "", true, false, false);
}
private static String mapUiAlias(String id) {

View file

@ -166,7 +166,7 @@ public final class MainSettingsComponent {
int text = active ? palette.menuHeaderText() : LayoutRender2D.alpha(palette.panelMuted(), 0.72f + 0.20f * hoverAnim);
ClickGuiRenderer.drawText(
ClickGuiRenderer.getInterMedium(),
category.title,
category.label(),
x + 13f * scale,
rowY + 6f * scale,
7f * scale,
@ -210,7 +210,7 @@ public final class MainSettingsComponent {
LayoutRender2D.alpha(palette.menuLineStrong(), 0.82f),
LayoutRender2D.alpha(palette.menuLineLow(), 0.55f)
);
ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), selected.title, rightX + pad, titleY, titleSize, text, false);
ClickGuiRenderer.drawText(ClickGuiRenderer.getInterRegular(), selected.label(), rightX + pad, titleY, titleSize, text, false);
contentX = rightX + pad;
contentY = rightY + 27f * scale;
@ -287,9 +287,10 @@ public final class MainSettingsComponent {
settingsByCategory.clear();
MainConfig config = MainConfig.get();
settingsByCategory.put(Category.IMAGE, buildSettings(config.getImageSettingDefs(), config));
settingsByCategory.put(Category.MAIN_MENU, buildSettings(config.getMainMenuSettingDefs(), config));
settingsByCategory.put(Category.MISCELLANIOUS, buildSettings(config.getMiscellaneousSettingDefs(), config));
settingsByCategory.put(Category.SECURITY, buildSettings(config.getSecuritySettingDefs(), config));
List<Setting> utility = buildSettings(config.getUtilitySettingDefs(), config);
List<Setting> utility = new java.util.ArrayList<>(buildSettings(config.getUtilitySettingDefs(), config));
utility.addAll(buildSettings(StyleConfig.get().getSettingDefs(), StyleConfig.get()));
utility.addAll(buildSettings(PlatformConfig.get().getSettingDefs(), PlatformConfig.get()));
settingsByCategory.put(Category.UTILITY, utility);

View file

@ -1,5 +1,7 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.main;
import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n;
import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting;
/**
@ -9,6 +11,7 @@ import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting;
*/
enum Category {
IMAGE("Image"),
MAIN_MENU("Main menu"),
SECURITY("Security"),
UTILITY("Utility"),
MISCELLANIOUS("Miscellanious");
@ -18,6 +21,11 @@ enum Category {
Category(String title) {
this.title = title;
}
/** Localized label; the English title is the fallback. */
String label() {
return ClickGuiI18n.tr("clickgui.settings.category." + name().toLowerCase(), title);
}
}
record SettingRow(Setting setting, float anim, float height, float gap) {

View file

@ -152,6 +152,8 @@ public abstract class BaseHudElement implements JsonConfigObject, ConfigNameProv
public final String getId() { return id; }
public final String getTitle() { return HudElementNamer.translateTitle(title, getTranslationKeyPrefix(), id); }
/** Short settings-card description from {@code hud.<id>.description}; empty when none is translated. */
public final String getDescription() { return HudElementNamer.translateDescription(id); }
public boolean isEnabled() { return enabled.get() || previewEnabled; }
public void setRuntimeEnabledTransient(boolean value) { enabled.set(value); }
public void setPreviewEnabled(boolean value) { previewEnabled = value; }

View file

@ -42,6 +42,14 @@ final class HudElementNamer {
return fallback == null ? "" : fallback;
}
/** Translated {@code hud.<id>.description}, or an empty string when the key is missing. */
static String translateDescription(String id) {
if (id == null || id.isBlank()) return "";
String key = "hud." + id + ".description";
String translated = I18n.get(key);
return translated.equals(key) ? "" : translated;
}
private static String stripOwnerPrefix(String value, String owner) {
if (value == null || value.isBlank() || owner == null || owner.isBlank()) return value;
String normalizedOwner = normalizePrefix(owner);

View file

@ -36,6 +36,9 @@ public final class ScriptedListHudPanel {
public static final Variant KEYBINDS = new Variant(
"keybinds", "Keybinds", "M",
102.0f, 18.0f, 5.0f, 22.0f, 22.0f, 3.0f, 3.5f, 8.0f, 15.0f, 6.0f, 18.0f, 8.0f, 1.95f, "Icons", 1.18f);
public static final Variant VOICECHAT = new Variant(
"voicechat", "Voice chat", "K",
96.0f, 18.0f, 5.0f, 22.0f, 22.0f, 3.0f, 3.5f, 8.0f, 15.0f, 6.0f, 18.0f, 8.0f, 1.95f);
public static final float HEADER_HEIGHT = 15.5f;
public static final float BODY_Y_OFFSET = 18.5f;
public static final float CONTENT_START_Y = 25.0f;

View file

@ -0,0 +1,78 @@
package dev.loki.lovisual.features.gui.hud.draggable.impl.voice;
import dev.loki.lovisual.config.definition.SettingDef;
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.gui.hud.config.HudBgStyles;
import dev.loki.lovisual.features.gui.hud.config.HudGlobalConfig;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.impl.HudPanelLayoutModes;
import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel;
import dev.loki.lovisual.features.gui.hud.registry.HudElementRegister;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import java.util.List;
/** Draggable Simple Voice Chat panel: who is talking right now plus the state of your own microphone. */
@HudElementRegister(order = 52)
public final class VoiceChatHud extends DraggableHudElement {
{
defaultLayout(16.0f, 150.0f);
}
final HudGlobalConfig hud = HudGlobalConfig.get();
final ScriptedListHudPanel panel = new ScriptedListHudPanel();
final NumberValue<Double> scale = new NumberValue<>("voicechat_scale", 1.0, HudPanelLayoutModes.SCALE_MIN, HudPanelLayoutModes.SCALE_MAX);
final ModeValue layoutMode = new ModeValue("voicechat_layout", "Split Header", HudPanelLayoutModes.SPLIT_HEADER, HudPanelLayoutModes.UNIFIED_DIVIDER);
final ModeValue bgEffect = new ModeValue("voicechat_bg_effect", HudBgStyles.AUTO, HudBgStyles.NONE, HudBgStyles.BLUR, HudBgStyles.AUTO);
final NumberValue<Integer> bgAlpha = new NumberValue<>("voicechat_bg_alpha", 131, 0, 255);
final NumberValue<Integer> blurAlpha = new NumberValue<>("voicechat_blur_alpha", 255, 0, 255);
final NumberValue<Integer> maxRows = new NumberValue<>("voicechat_max_rows", 6, 1, 12);
final NumberValue<Integer> holdMs = new NumberValue<>("voicechat_hold_ms", 350, 100, 1500);
final BooleanValue showSelf = new BooleanValue("voicechat_show_self", true);
float displayWidth = -1.0f;
float displayHeight = -1.0f;
float visibilityAnim = 0.0f;
public VoiceChatHud() {
super("voicechat", "VoiceChat", false);
}
@Override
protected void defineSettings(List<SettingDef> defs) {
defs.add(SettingDef.number(scale));
defs.add(SettingDef.mode(layoutMode));
defs.add(SettingDef.number(maxRows));
defs.add(SettingDef.number(holdMs));
defs.add(SettingDef.bool(showSelf));
defs.add(SettingDef.number(bgAlpha));
defs.add(SettingDef.mode(bgEffect));
defs.add(SettingDef.number(blurAlpha).visibleWhen(() -> HudBgStyles.hasEffect(bgEffect.get())));
}
@Override
public void applyDefaultPosition(int screenW, int screenH) {
this.x = 16.0f;
this.y = 150.0f;
}
@Override
public boolean usesEngineRenderer() {
return true;
}
@Override
public void renderEngine(Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx,
float tickDelta, int screenW, int screenH) {
VoiceChatHudRenderer.render(this, renderer, textRenderer, ctx, tickDelta, screenW, screenH);
}
void setSize(float w, float h) {
setBounds(getX(), getY(), w, h);
}
}

View file

@ -0,0 +1,177 @@
package dev.loki.lovisual.features.gui.hud.draggable.impl.voice;
import dev.loki.lovisual.compat.voicechat.VoiceChatBridge;
import dev.loki.lovisual.compat.voicechat.VoiceState;
import dev.loki.lovisual.features.gui.hud.config.HudBgStyles;
import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.features.gui.hud.draggable.impl.HudPanelLayoutModes;
import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel;
import dev.loki.lovisual.features.gui.hud.draggable.impl.scripted.ScriptedListHudPanel.Palette;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.font.Fonts;
import dev.loki.lovisual.render.engine.text.font.info.FontInfo;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.util.player.health.PlayerSkinResolver;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.ChatScreen;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.client.resources.language.I18n;
import net.minecraft.resources.Identifier;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import static dev.loki.lovisual.features.theme.Theme.theme;
/** Builds the rows of {@link VoiceChatHud} and feeds them to the shared scripted list panel. */
final class VoiceChatHudRenderer {
private static final int TALKING = 0xFF55FF77;
private static final int WHISPER = 0xFFFFD255;
private static final int SILENT = 0xFFFF5555;
private static final float WIDTH_PAD = 30.0f;
private VoiceChatHudRenderer() {
}
private record Row(String key, Identifier skin, String name, String status, int statusColor, float alpha) { }
static void render(VoiceChatHud host, Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx,
float tickDelta, int screenW, int screenH) {
Minecraft mc = Minecraft.getInstance();
boolean preview = DraggableHudElementRegistry.isForceVisible() || ClientScreen.current() instanceof ChatScreen;
if (mc == null || (mc.player == null && !preview)) { host.setSize(0f, 0f); return; }
List<Row> rows = collectRows(host, mc, preview);
host.visibilityAnim = HudRenderUtil.animateVisibility(host.visibilityAnim, !rows.isEmpty());
if (rows.isEmpty() && host.visibilityAnim <= 0.0f) { host.setSize(0f, 0f); return; }
TextRenderer header = font("Inter", FontInfo.Type.Bold, textRenderer);
TextRenderer body = font("OnestMedium", FontInfo.Type.Regular, textRenderer);
TextRenderer icons = font("IconsNur", FontInfo.Type.Regular, textRenderer);
float base = HudScale.scale(screenW, screenH) * 1.1f * HudPanelLayoutModes.effectiveScale(host.scale);
float fontScale = 0.98f * (host.hud.getFontSize() / 18.0f);
float iconH = height(icons, fontScale);
float titleH = height(header, fontScale);
float rowH = height(body, fontScale);
float titleW = width(header, I18n.get("hud.voicechat.title"), fontScale);
float labelW = width(body, "Active:", fontScale * 0.92f);
float countW = width(body, Integer.toString(rows.size()), fontScale * 0.92f);
float maxW = Math.max(ScriptedListHudPanel.VOICECHAT.minWidth() * base,
(ScriptedListHudPanel.VOICECHAT.titleTextX() * base) + titleW + labelW + countW + 34.0f * base);
for (Row r : rows) {
maxW = Math.max(maxW, width(body, r.name(), fontScale) + width(body, r.status(), fontScale) + WIDTH_PAD * base);
}
float targetH = ScriptedListHudPanel.CONTENT_START_Y * base + rows.size() * ScriptedListHudPanel.ROW_STEP * base;
host.displayWidth = HudRenderUtil.animateDimension(host.displayWidth, maxW);
host.displayHeight = HudRenderUtil.animateDimension(host.displayHeight, rows.isEmpty() ? 0f : targetH);
host.setSize(host.displayWidth, host.displayHeight);
float s = Math.max(0.0f, HudRenderUtil.visibilityScale(host.visibilityAnim));
float w = host.displayWidth * s;
float h = host.displayHeight * s;
if (w <= 0.0f || h <= 0.0f) return;
Palette palette = palette(host.bgAlpha.get());
boolean blur = HudBgStyles.hasEffect(host.bgEffect.get());
host.panel.render(renderer, textRenderer, ctx, tickDelta, new ScriptedListHudPanel.Panel(
ScriptedListHudPanel.VOICECHAT, palette,
host.getX() + (host.displayWidth - w) * 0.5f, host.getY() + (host.displayHeight - h) * 0.5f, w, h,
s, base * s, fontScale * s, iconH * s, titleH * s, rowH * s, labelW * s, countW * s, rows.size(),
blur, Math.min(1.0f, host.blurAlpha.get() / 255.0f * 1.15f), palette.counter(),
HudPanelLayoutModes.current(host.layoutMode), toPanelRows(rows, palette)));
}
private static List<Row> collectRows(VoiceChatHud host, Minecraft mc, boolean preview) {
if (!VoiceChatBridge.isInstalled()) {
return List.of(new Row("missing", null, I18n.get("hud.voicechat.not_found"), "", SILENT, 1.0f));
}
VoiceState state = VoiceChatBridge.state();
long hold = host.holdMs.get();
List<Row> rows = new java.util.ArrayList<>();
if (host.showSelf.get() && mc.player != null) {
VoiceState.MicStatus mic = state.micStatus(hold);
rows.add(new Row("self", PlayerSkinResolver.resolveProfileSkin(mc.player.getGameProfile()),
mc.player.getName().getString(), I18n.get("hud.voicechat.status." + mic.name().toLowerCase()),
statusColor(mic), 1.0f));
}
for (VoiceState.Speaker sp : state.speakers(hold, host.maxRows.get())) {
PlayerInfo info = mc.getConnection() != null ? mc.getConnection().getPlayerInfo(sp.id()) : null;
String name = info != null && info.getProfile() != null ? info.getProfile().name() : shortId(sp.id());
Identifier skin = info != null ? PlayerSkinResolver.resolveProfileSkin(info.getProfile()) : null;
rows.add(new Row(sp.id().toString(), skin, name, sp.whisper() ? I18n.get("hud.voicechat.whisper") : "",
sp.whisper() ? WHISPER : TALKING, state.level(sp.id(), hold) * 0.6f + 0.4f));
}
if (rows.isEmpty() && preview && mc.player != null) {
rows.add(new Row("example", PlayerSkinResolver.resolveProfileSkin(mc.player.getGameProfile()),
"Example Player", "", TALKING, 1.0f));
}
return rows;
}
private static List<LinkedHashMap<String, Object>> toPanelRows(List<Row> rows, Palette p) {
List<LinkedHashMap<String, Object>> out = ScriptedListHudPanel.rows();
for (Row r : rows) {
int alpha = Math.round(255.0f * r.alpha());
out.add(ScriptedListHudPanel.row(r.key(), r.skin() != null ? "head" : "", ScriptedListHudPanel.idString(r.skin()),
List.of(ScriptedListHudPanel.textPart(r.name(), withAlpha(p.text(), alpha), 0.0f)),
r.status(), withAlpha(r.statusColor(), alpha), withAlpha(p.muted(), Math.max(26, alpha - 120)), r.alpha()));
}
return out;
}
private static int statusColor(VoiceState.MicStatus mic) {
return switch (mic) {
case TALKING -> TALKING;
case WHISPERING -> WHISPER;
case MUTED, DISABLED, DISCONNECTED -> SILENT;
default -> theme().textMuted();
};
}
/** Theme-driven palette, the same recipe the other list panels use in theme mode. */
private static Palette palette(int alpha) {
int window = HudRenderUtil.setAlpha(theme().windowBg(), alpha);
int header = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().windowHeader(), theme().surface(), 0.18f), Math.min(255, alpha + 14));
int surface = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().surface(), theme().windowBg(), 0.22f), Math.min(255, alpha + 6));
int deep = HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().surface(), theme().windowHeader(), 0.42f), Math.min(255, alpha + 18));
int headerRight = HudRenderUtil.mixColor(surface, header, 0.52f);
int bodyRight = HudRenderUtil.mixColor(deep, surface, 0.18f);
int muted = HudRenderUtil.mixColor(theme().textMuted(), theme().textPrimary(), 0.16f);
return new Palette(HudRenderUtil.mixColor(header, window, 0.22f), headerRight,
HudRenderUtil.mixColor(window, surface, 0.24f), bodyRight,
HudRenderUtil.setAlpha(HudRenderUtil.mixColor(theme().windowStroke(), theme().strokeSoft(), 0.18f), Math.min(255, Math.max(182, alpha + 36))),
theme().textPrimary(), muted, HudRenderUtil.mixColor(theme().accent(), theme().textPrimary(), 0.38f),
HudRenderUtil.mixColor(theme().textPrimary(), theme().accent(), 0.14f), HudRenderUtil.scaleAlpha(muted, 0.68f),
HudRenderUtil.mixColor(HudRenderUtil.mixColor(headerRight, bodyRight, 0.5f), theme().accent(), 0.14f));
}
private static int withAlpha(int rgb, int alpha) {
return (Math.max(0, Math.min(255, alpha)) << 24) | (rgb & 0x00FFFFFF);
}
private static String shortId(UUID id) {
return id.toString().substring(0, 8);
}
private static TextRenderer font(String family, FontInfo.Type type, TextRenderer fallback) {
TextRenderer r = Fonts.renderer(family, type, TextRenderer.get());
return r != null ? r : fallback;
}
private static float height(TextRenderer r, float size) {
r.begin(size, true, false);
float v = (float) r.getHeight(false);
r.end();
return v;
}
private static float width(TextRenderer r, String text, float size) {
r.begin(size, true, false);
float v = (float) r.getWidth(text, false);
r.end();
return v;
}
}

View file

@ -96,7 +96,7 @@ public final class ButtonQueue {
public static void captureTooltip(AbstractWidget widget, int mouseX, int mouseY) {
if (widget == null) return;
BetterTooltips tooltips = BetterTooltips.get();
if (tooltips == null || !tooltips.useCustomGuiTooltips()) {
if (tooltips == null || !tooltips.useButtonTooltips()) {
return;
}
WidgetTooltipHolder state = ((ClickableWidgetAccessor) widget).lovisual$getTooltipState();

View file

@ -128,10 +128,14 @@ public final class BetterTooltips extends AbstractHudElement {
for (ConfigValue<?> v : vs) { SettingDef d = settingDef(v); if (d != null) defs.add(d); }
}
public boolean useCustomGuiTooltips() { BetterButtons b = BetterButtons.get(); if (b != null && b.useUiButtons()) return true; return isEnabled(); }
public boolean useCustomItemTooltips() { BetterButtons b = BetterButtons.get(); if (b != null && b.useUiButtons()) return true; return itemTooltipEnabled.get(); }
public boolean isItemInfoColorizeEnabled() { return itemInfoColorize.get(); }
public boolean isTooltipAlphaEnabled() { return anyTooltipsEnabled(); }
/** Every custom tooltip feature is gated by the module toggle: when it is off, tooltips are fully vanilla. */
public boolean useCustomGuiTooltips() { return isEnabled(); }
public boolean useCustomItemTooltips() { return isEnabled() && itemTooltipEnabled.get(); }
/** BetterButtons draws its own button hints through this panel even while the module is off. */
public boolean useButtonTooltips() { BetterButtons b = BetterButtons.get(); return (b != null && b.useUiButtons()) || isEnabled(); }
public boolean isItemInfoColorizeEnabled() { return isEnabled() && itemInfoColorize.get(); }
public boolean isTooltipAlphaEnabled() { return isEnabled(); }
public boolean isShulkerPreviewActive() { return isEnabled() && shulkerPreviewEnabled.get(); }
public int tooltipColor() { return (TOOLTIP_BG_ALPHA << 24) | 0x00101114; }
public boolean isShulkerPreviewEnabled() { return shulkerPreviewEnabled.get(); }
public void setShulkerPreviewEnabled(boolean v) { shulkerPreviewEnabled.set(v); saveConfig(); }
@ -154,7 +158,6 @@ public final class BetterTooltips extends AbstractHudElement {
public float shulkerPreviewSlotGap() { return SHULKER_SLOT_GAP; }
public float shulkerPreviewPadding() { return SHULKER_PADDING; }
private boolean anyTooltipsEnabled() { if (useCustomGuiTooltips()) return true; if (itemTooltipEnabled.get()) return true; return shulkerPreviewEnabled.get(); }
private String bindingName(KeyBindValue v) { return name() + ":" + v.getName(); }
private void registerBind(KeyBindValue v) {
String n = bindingName(v); KeyManager.unregisterAll(n);

View file

@ -26,7 +26,7 @@ final class ShulkerPreviewCache {
static PreviewLayout buildLayout() {
BetterTooltips self = BetterTooltips.get();
if (self == null || !self.isShulkerPreviewEnabled() || !self.isShulkerPreviewHeld()) return null;
if (self == null || !self.isShulkerPreviewActive() || !self.isShulkerPreviewHeld()) return null;
ItemStack toolStack = TooltipState.getStack();
GuiGraphicsExtractor ctx = TooltipState.getCtx();
if (toolStack == null || toolStack.isEmpty() || !isShulkerBox(toolStack) || ctx == null) return null;

View file

@ -0,0 +1,22 @@
package dev.loki.lovisual.features.gui.hud.nondraggable.impl.tooltips;
/** Keeps vanilla tooltip text readable on the themed panel. */
final class TooltipColors {
private static final int VANILLA_BLUE = 0x5555FF;
private static final int VANILLA_DARK_BLUE = 0x0000AA;
private TooltipColors() {
}
/**
* Vanilla paints creative-tab lines pure blue, which is hard to read on a dark themed panel;
* those two shades are replaced by the theme accent, every other color is kept.
*/
static int readable(int argb, int accentArgb) {
int rgb = argb & 0x00FFFFFF;
if (rgb == VANILLA_BLUE || rgb == VANILLA_DARK_BLUE) {
return 0xFF000000 | (accentArgb & 0x00FFFFFF);
}
return argb;
}
}

View file

@ -111,6 +111,7 @@ final class TooltipState {
static List<TooltipLine> convertOrdered(List<? extends FormattedCharSequence> src) {
List<TooltipLine> out = new ArrayList<>();
int fallback = Theme.theme().textPrimary();
int accent = Theme.theme().accent();
for (FormattedCharSequence line : src) {
StringBuilder sb = new StringBuilder();
int[] color = new int[]{0};
@ -122,7 +123,7 @@ final class TooltipState {
return true;
});
if (sb.length() == 0) sb.append(' ');
int c = color[0] != 0 ? color[0] : fallback;
int c = color[0] != 0 ? TooltipColors.readable(color[0], accent) : fallback;
out.add(new TooltipLine(sb.toString(), c));
}
return out;
@ -149,7 +150,7 @@ final class TooltipState {
private static boolean shouldShowHint(boolean isShulker) {
if (!isShulker) return false;
BetterTooltips b = BetterTooltips.get();
if (b == null || !b.isShulkerPreviewEnabled()) return false;
if (b == null || !b.isShulkerPreviewActive()) return false;
if (!ShulkerPreviewCache.hasShulkerContents(stack)) return false;
return !b.isShulkerPreviewHeld();
}

View file

@ -0,0 +1,41 @@
package dev.loki.lovisual.features.gui.mainmenu;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.features.gui.mainmenu.hero.LoVisualHeroMenuScreen;
import dev.loki.lovisual.util.logging.DebugLog;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;
/** Picks the main menu implementation from {@code menuLayout} and owns the vanilla fallback. */
public final class MainMenus {
private MainMenus() {
}
public static Screen create() {
MainConfig cfg = MainConfig.get();
boolean hero = cfg != null && "hero".equalsIgnoreCase(cfg.getMenuLayout());
return hero ? new LoVisualHeroMenuScreen() : new LoVisualMainMenuScreen();
}
public static boolean isMainMenu(Screen screen) {
return screen instanceof LoVisualMainMenuScreen || screen instanceof LoVisualHeroMenuScreen;
}
public static boolean shouldUseVanillaTitleScreen() {
return LoVisualMainMenuScreen.shouldUseVanillaTitleScreen();
}
/** Shows the vanilla title screen; a non-null error also disables the custom menu for this session. */
public static void fallbackToVanilla(Throwable error) {
if (error != null) {
LoVisualMainMenuScreen.forceVanillaTitleScreen = true;
DebugLog.errorOnce("main_menu_vanilla_fallback", "Main menu failed, switching to vanilla title screen", error);
}
Minecraft mc = Minecraft.getInstance();
if (mc != null && !(ClientScreen.current() instanceof TitleScreen)) {
ClientScreen.show(mc, new TitleScreen(false));
}
}
}

View file

@ -0,0 +1,200 @@
package dev.loki.lovisual.features.gui.mainmenu.hero;
import dev.loki.lovisual.features.account.AccountConfig;
import dev.loki.lovisual.features.gui.mainmenu.LoVisualAddonManagerScreen;
import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil;
import dev.loki.lovisual.features.gui.mainmenu.MainMenus;
import dev.loki.lovisual.features.gui.mainmenu.alt.LoVisualAltManagerScreen;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout;
import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroButtonPainter;
import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroMenuRenderer;
import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint;
import dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroWidgetPainter;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.features.theme.Themes;
import dev.loki.lovisual.render.engine.core.context.ViewportContext;
import dev.loki.lovisual.render.engine.math.HudScale;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Mth;
import net.minecraft.util.Util;
import java.time.LocalTime;
import java.util.List;
import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.easeOutCubic;
/**
* "Hero" main-menu layout: greeting and big title on the left, account card and clock widget on
* the top corners. Reuses the shared shader background, the alt manager and the global themes.
*/
public final class LoVisualHeroMenuScreen extends Screen {
private static final long APPEAR_MS = 700L;
private static final int THEME_DOTS = 6;
private final float[] hover = new float[HeroLayout.BUTTON_COUNT];
private float accountHover;
private int hoveredDot = -1;
private int hoveredButton = -1;
private boolean hoveredAccount;
private boolean initialized;
private long openTime;
private long lastTime;
private Layout layout;
private float appear;
public LoVisualHeroMenuScreen() {
super(Component.literal("LoVisual"));
}
@Override
protected void init() {
initialized = false;
}
@Override
public void extractRenderState(GuiGraphicsExtractor ctx, int mouseX, int mouseY, float delta) {
Minecraft mc = minecraft;
if (mc == null) return;
if (MainMenus.shouldUseVanillaTitleScreen() || !HeroMenuRenderer.background(mc)) {
MainMenus.fallbackToVanilla(null);
return;
}
long now = Util.getMillis();
if (!initialized) {
initialized = true;
openTime = now;
lastTime = now;
}
float dt = Mth.clamp((now - lastTime) / 1000f, 0f, 0.1f);
lastTime = now;
appear = easeOutCubic(Mth.clamp((now - openTime) / (float) APPEAR_MS, 0f, 1f));
try {
ViewportContext.beginUnscaledLogical(ctx);
updateLayout(mc);
updateHover(toFixed(mouseX, mc), toFixed(mouseY, mc), dt);
Renderer2D.COLOR.begin();
draw(ctx, mc);
Renderer2D.COLOR.render();
} catch (Throwable t) {
MainMenus.fallbackToVanilla(t);
} finally {
ViewportContext.end(ctx);
}
}
private void updateLayout(Minecraft mc) {
int fbw = mc.getWindow().getWidth();
int fbh = mc.getWindow().getHeight();
float rise = (1f - appear) * 18f * HeroLayout.scale(MainMenuTextUtil.virtualHeight(fbw, fbh));
layout = HeroLayout.compute(MainMenuTextUtil.virtualWidth(fbw, fbh), MainMenuTextUtil.virtualHeight(fbw, fbh),
dots().size(), rise, HeroPaint.metrics());
}
private void updateHover(float mx, float my, float dt) {
boolean live = appear > 0.8f;
hoveredButton = live ? HeroLayout.buttonAt(layout, mx, my) : -1;
hoveredDot = live ? HeroLayout.dotAt(layout, dots().size(), mx, my) : -1;
hoveredAccount = live && layout.account().contains(mx, my);
float step = Math.min(1f, dt * 12f);
for (int i = 0; i < hover.length; i++) {
hover[i] = Mth.lerp(step, hover[i], hoveredButton == i ? 1f : 0f);
}
accountHover = Mth.lerp(step, accountHover, hoveredAccount ? 1f : 0f);
}
private void draw(GuiGraphicsExtractor ctx, Minecraft mc) {
float w = MainMenuTextUtil.virtualWidth(mc.getWindow().getWidth(), mc.getWindow().getHeight());
float h = MainMenuTextUtil.virtualHeight(mc.getWindow().getWidth(), mc.getWindow().getHeight());
String name = accountName(mc);
boolean needsAuth = AccountConfig.get().getMicrosoftAuthorizationRequiredAccount() != null;
HeroMenuRenderer.vignette(w, h, appear);
HeroMenuRenderer.account(ctx, layout, appear, accountHover, name, needsAuth);
HeroWidgetPainter.draw(layout, appear, dots(), Theme.currentId(), hoveredDot, LocalTime.now());
HeroMenuRenderer.hero(layout, appear, name);
for (int i = 0; i < HeroLayout.BUTTON_COUNT; i++) {
HeroButtonPainter.draw(layout, i, appear, hover[i], needsAuth && i == HeroLayout.ACCOUNTS);
}
HeroMenuRenderer.footer(layout, appear, mc.getLaunchedVersion());
}
private static List<Themes.ThemeEntry> dots() {
List<Themes.ThemeEntry> presets = Themes.presetEntries();
return presets.subList(0, Math.min(THEME_DOTS, presets.size()));
}
private static String accountName(Minecraft mc) {
String active = AccountConfig.get().getActiveAccountName();
if (active != null && !active.isBlank()) return active;
return mc.getUser() != null ? mc.getUser().getName() : "LoVisual";
}
private static float toFixed(float screen, Minecraft mc) {
float scale = HudScale.scale(mc.getWindow().getWidth(), mc.getWindow().getHeight());
return scale <= 0f ? screen : screen * mc.getWindow().getGuiScale() / scale;
}
@Override
public boolean mouseClicked(MouseButtonEvent click, boolean doubled) {
Minecraft mc = minecraft;
if (click.button() != 0 || mc == null || layout == null || appear <= 0.8f) return true;
float mx = toFixed((float) click.x(), mc);
float my = toFixed((float) click.y(), mc);
int dot = HeroLayout.dotAt(layout, dots().size(), mx, my);
if (dot >= 0) {
Theme.setTheme(dots().get(dot).id());
} else if (layout.account().contains(mx, my)) {
open(mc, HeroLayout.ACCOUNTS);
} else {
int button = HeroLayout.buttonAt(layout, mx, my);
if (button >= 0) open(mc, button);
}
return true;
}
private void open(Minecraft mc, int button) {
switch (button) {
case HeroLayout.MULTIPLAYER -> ClientScreen.show(mc, new JoinMultiplayerScreen(this));
case HeroLayout.SINGLEPLAYER -> ClientScreen.show(mc, new SelectWorldScreen(this));
case HeroLayout.ACCOUNTS -> ClientScreen.show(mc, new LoVisualAltManagerScreen(this,
AccountConfig.get().getMicrosoftAuthorizationRequiredAccount() != null));
case HeroLayout.ADDONS -> ClientScreen.show(mc, new LoVisualAddonManagerScreen(this));
case HeroLayout.OPTIONS -> ClientScreen.show(mc, new OptionsScreen(this, mc.options, false));
case HeroLayout.QUIT -> mc.stop();
default -> { }
}
}
@Override
public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
}
@Override
public void mouseMoved(double mouseX, double mouseY) {
}
@Override
public boolean keyPressed(KeyEvent input) {
return input.key() == 256 || super.keyPressed(input);
}
@Override
public boolean shouldCloseOnEsc() {
return false;
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.logic;
/** Time-of-day helpers for the hero menu clock widget. */
public final class DayCycle {
public static final int MORNING = 0;
public static final int DAY = 1;
public static final int EVENING = 2;
public static final int NIGHT = 3;
private DayCycle() {
}
public static int period(int hour) {
if (hour >= 5 && hour < 12) return MORNING;
if (hour >= 12 && hour < 17) return DAY;
if (hour >= 17 && hour < 22) return EVENING;
return NIGHT;
}
/** Fraction of the day elapsed, in [0, 1). */
public static float progress(int hour, int minute) {
return (hour * 60 + minute) / 1440.0f;
}
/** Centre x of the bar thumb; the thumb never leaves the rounded bar ends. */
public static float thumbX(float barX, float barW, float barH, float progress) {
float r = barH * 0.5f;
float p = Math.max(0f, Math.min(1f, progress));
return barX + r + p * (barW - r * 2f);
}
}

View file

@ -0,0 +1,130 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.logic;
/**
* Geometry of the hero main menu, computed from the logical viewport and measured text heights,
* so lines never overlap whatever the resolution or GUI scale. Free of Minecraft types.
*/
public final class HeroLayout {
public static final int BUTTON_COUNT = 6;
public static final int MULTIPLAYER = 0;
public static final int SINGLEPLAYER = 1;
public static final int ACCOUNTS = 2;
public static final int ADDONS = 3;
public static final int OPTIONS = 4;
public static final int QUIT = 5;
private static final float FONT_UNIT_PX = 12f;
private static final float MIN_K = 0.45f;
/** Font sizes in logical pixels. */
public record Sizes(float tag, float title, float sub, float label, float small, float footer, float time) {
}
/** @param titleY1 first title line; @param titleY2 second title line */
public record Layout(float k, Rect account, float avatarSize, Rect widget, float pad,
float dotsStartX, float dotY, float dotSpacing, float dotRadius,
Rect bar, Rect[] buttons, Sizes sizes,
float heroX, float tagY, float titleY1, float titleY2, float subY,
float footerY, float footerLine) {
}
private HeroLayout() {
}
public static float scale(float viewportHeight) {
return Math.max(0.7f, viewportHeight / 1080f * 1.25f);
}
/** Converts logical pixels to the text renderer's size units. */
public static float font(float px) {
return px / FONT_UNIT_PX;
}
/** @param rise downward slide offset while fading in */
public static Layout compute(float width, float height, int dotCount, float rise, TextMetrics m) {
float k = scale(height);
Layout l = build(width, height, dotCount, rise, m, k);
for (int i = 0; i < 3 && !fits(l); i++) {
k = Math.max(MIN_K, k * 0.92f);
l = build(width, height, dotCount, rise, m, k);
}
return l;
}
private static boolean fits(Layout l) {
Rect last = l.buttons()[QUIT];
return last.y() + last.h() + 14f * l.k() <= l.footerY() || l.k() <= MIN_K;
}
private static Layout build(float width, float height, int dotCount, float rise, TextMetrics m, float k) {
Sizes s = new Sizes(9.5f * k, 26f * k, 11f * k, 12.5f * k, 11.5f * k, 9f * k, 16f * k);
float avatar = 30f * k;
float topY = 24f * k + rise * 0.3f;
Rect account = new Rect(32f * k, topY, avatar + 140f * k, avatar);
float pad = 10f * k;
float ww = 215f * k;
float timeH = m.height(s.time());
float barH = 10f * k;
float wh = pad + timeH + 12f * k + barH + pad;
Rect widget = new Rect(width - ww - 24f * k, topY, ww, wh);
float spacing = 13f * k;
float dotsStart = widget.x() + ww - pad - dotCount * spacing + 6f * k;
float dotY = widget.y() + pad + timeH * 0.5f;
Rect bar = new Rect(widget.x() + pad, widget.y() + wh - pad - barH, ww - pad * 2f, barH);
float footerLine = m.height(s.footer()) + 3f * k;
float footerY = height - 26f * k - footerLine * 3f;
float tagH = m.height(s.tag());
float titleH = m.height(s.title());
float subH = m.height(s.sub());
float buttonH = Math.max(38f * k, m.height(s.label()) + 22f * k);
float gap = 8f * k;
float tagGap = 10f * k;
float subGap = 14f * k;
float buttonsGap = 22f * k;
float blockH = tagH + tagGap + titleH * 2f + 2f * k + subGap + subH + buttonsGap + buttonH * 5f + gap * 4f;
float top = account.y() + avatar + 30f * k;
float room = footerY - 14f * k - top;
float heroY = top + Math.max(0f, (room - blockH) * 0.42f) + rise;
float heroX = width * 0.045f;
float titleY1 = heroY + tagH + tagGap;
float titleY2 = titleY1 + titleH + 2f * k;
float subY = titleY2 + titleH + subGap;
Rect[] buttons = buttons(heroX, subY + subH + buttonsGap, k, buttonH, gap);
return new Layout(k, account, avatar, widget, pad, dotsStart, dotY, spacing, 4.2f * k, bar, buttons, s,
heroX, heroY, titleY1, titleY2, subY, footerY, footerLine);
}
private static Rect[] buttons(float x, float top, float k, float h, float gap) {
float w = 290f * k;
Rect[] out = new Rect[BUTTON_COUNT];
for (int i = 0; i <= ADDONS; i++) {
out[i] = new Rect(x, top + i * (h + gap), w, h);
}
float rowY = top + (ADDONS + 1) * (h + gap);
float optionsW = (w - gap) * 0.55f;
out[OPTIONS] = new Rect(x, rowY, optionsW, h);
out[QUIT] = new Rect(x + optionsW + gap, rowY, w - gap - optionsW, h);
return out;
}
/** Index of the button under the cursor, or -1. */
public static int buttonAt(Layout layout, float mx, float my) {
for (int i = 0; i < layout.buttons().length; i++) {
if (layout.buttons()[i].contains(mx, my)) return i;
}
return -1;
}
/** Index of the theme dot under the cursor, or -1. */
public static int dotAt(Layout layout, int dotCount, float mx, float my) {
float r = layout.dotRadius() + 3f * layout.k();
for (int i = 0; i < dotCount; i++) {
float dx = mx - (layout.dotsStartX() + i * layout.dotSpacing());
float dy = my - layout.dotY();
if (dx * dx + dy * dy <= r * r) return i;
}
return -1;
}
}

View file

@ -0,0 +1,12 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.logic;
/** Axis-aligned rectangle in the fixed logical viewport. */
public record Rect(float x, float y, float w, float h) {
public boolean contains(float px, float py) {
return px >= x && px <= x + w && py >= y && py <= y + h;
}
public Rect offset(float dx, float dy) {
return new Rect(x + dx, y + dy, w, h);
}
}

View file

@ -0,0 +1,7 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.logic;
/** Measures text in logical pixels so the layout can be built from real glyph sizes. */
public interface TextMetrics {
/** Line height of the bold UI font at {@code px} logical pixels. */
float height(float px);
}

View file

@ -0,0 +1,59 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.paint;
import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.resources.language.I18n;
import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*;
/** Hero menu buttons: accent primary, dark glass secondaries, a red-tinted quit, icon on the left. */
public final class HeroButtonPainter {
private static final String[] LABEL_KEYS = {"multiplayer", "singleplayer", "accounts", "addons", "options", "quit"};
private static final String[] ICONS = {"swords", "tree-deciduous", "user", "package", "columns-3-cog", "cross"};
private HeroButtonPainter() {
}
public static void draw(Layout l, int index, float a, float hover, boolean badge) {
float k = l.k();
Rect r = l.buttons()[index];
float rad = 9f * k;
boolean compact = index >= HeroLayout.OPTIONS;
fill(r, rad, k, a, hover, index);
float iconSize = (compact ? 14f : 17f) * k;
float iconX = r.x() + (compact ? 13f : 16f) * k + iconSize * 0.5f;
float nudge = hover * 1.2f * k;
icon(ICONS[index], iconX, r.y() + r.h() * 0.5f - nudge * 0.2f, iconSize + nudge, argb(0xFFFFFF, a * (0.85f + hover * 0.15f)));
TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get());
float px = (compact ? l.sizes().small() : l.sizes().label());
float textX = iconX + iconSize * 0.5f + (compact ? 8f : 11f) * k;
textCentered(bold, I18n.get("screen.lovisual.hero_menu." + LABEL_KEYS[index]), textX, r.y(), r.h(), px, argb(0xFFFFFF, a));
if (badge) {
Renderer2D.COLOR.circle(r.x() + r.w() - 14f * k, r.y() + r.h() * 0.5f, 3.6f * k, argb(0xFFB74D, a));
}
}
private static void fill(Rect r, float rad, float k, float a, float hover, int index) {
int accent = Theme.theme().accent();
if (index == HeroLayout.MULTIPLAYER) {
Renderer2D.COLOR.roundedRectSoftShadow(r.x(), r.y() + 3f * k, r.w(), r.h(), rad, 18f * k, 0.05f + hover * 0.06f, argb(accent, a * 0.55f));
int top = argb(mix(accent, 0xFFFFFF, 0.10f + hover * 0.12f), a);
int bottom = argb(mix(accent, 0x000000, 0.10f - hover * 0.04f), a);
Renderer2D.COLOR.roundedRectGradientQuad(r.x(), r.y(), r.w(), r.h(), rad, top, top, bottom, bottom);
return;
}
boolean quit = index == HeroLayout.QUIT;
int base = quit ? mix(0x1C1E26, 0xB0303C, hover * 0.8f) : mix(0x1C1E26, 0x2E3240, hover);
Renderer2D.COLOR.roundedRectSoftShadow(r.x(), r.y() + 2f * k, r.w(), r.h(), rad, 10f * k, 0.03f, argb(0x000000, a * 0.4f));
Renderer2D.COLOR.roundedRect(r.x(), r.y(), r.w(), r.h(), rad, argb(base, a * (0.76f + hover * 0.16f)));
Renderer2D.COLOR.roundedRectStrokeGradient(r.x(), r.y(), r.w(), r.h(), rad, 1f,
argb(0xFFFFFF, a * (0.10f + hover * 0.14f)), argb(0xFFFFFF, a * (0.03f + hover * 0.05f)), 90f);
}
}

View file

@ -0,0 +1,85 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.paint;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.features.account.SkinManager;
import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.postprocess.render.MenuBackgroundRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.helpers.player.PlayerHeadRenderer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.resources.language.I18n;
import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*;
/** Draw pass of the hero menu: background, vignette, account card, hero text, buttons and footer. */
public final class HeroMenuRenderer {
private HeroMenuRenderer() {
}
/** Draws the shader background; returns false when the custom menu is switched off. */
public static boolean background(Minecraft mc) {
MainConfig cfg = MainConfig.get();
String mode = cfg != null ? cfg.getMenuBackgroundMode() : "off";
if (mode == null || mode.equalsIgnoreCase("off")) return false;
MenuBackgroundRenderer.render(mc, mode.equalsIgnoreCase("aurora"));
return true;
}
public static void vignette(float w, float h, float a) {
int dark = argb(0x0A0C10, a * 0.88f);
int mid = argb(0x0A0C10, a * 0.45f);
int clear = argb(0x0A0C10, 0f);
Renderer2D.COLOR.quad(0, 0, w * 0.27f, h, dark, mid, mid, dark);
Renderer2D.COLOR.quad(w * 0.27f, 0, w * 0.27f, h, mid, clear, clear, mid);
}
public static void account(GuiGraphicsExtractor ctx, Layout l, float a, float hover, String name, boolean needsAuth) {
float k = l.k();
Rect r = l.account();
int accent = Theme.theme().accent();
Renderer2D.COLOR.roundedRect(r.x(), r.y(), l.avatarSize(), l.avatarSize(), 6f * k, argb(0xFFFFFF, a * (0.07f + hover * 0.07f)));
PlayerHeadRenderer.drawRounded(ctx, r.x(), r.y(), l.avatarSize(), 6f * k, SkinManager.getSkin(name),
new RenderColor(argb(0xFFFFFF, a)), true, new RenderColor(argb(0xFFFFFF, a * 0.14f)), 0.8f, false);
TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get());
TextRenderer medium = MainMenuTextUtil.interMedium(bold);
float tx = r.x() + l.avatarSize() + l.sizes().footer();
String caption = needsAuth ? I18n.get("screen.lovisual.hero_menu.sign_in") : I18n.get("screen.lovisual.hero_menu.account");
float capH = height(medium, 8.5f * k);
float nameH = height(bold, 11f * k);
float top = r.y() + (l.avatarSize() - capH - nameH - 2f * k) * 0.5f;
text(medium, caption, tx, top, 8.5f * k, needsAuth ? argb(0xFFB74D, a) : argb(0xFFFFFF, a * 0.55f));
text(bold, name, tx, top + capH + 2f * k, 11f * k, argb(0xFFFFFF, a));
if (needsAuth) {
Renderer2D.COLOR.circle(r.x() + l.avatarSize() - 2f * k, r.y() + 2f * k, 3.4f * k, argb(0xFFB74D, a));
}
if (hover > 0.01f) {
Renderer2D.COLOR.roundedRectStrokeGradient(r.x() - 4f * k, r.y() - 4f * k, r.w() + 8f * k, r.h() + 8f * k, l.sizes().footer(), 1f,
argb(accent, a * hover * 0.5f), argb(accent, a * hover * 0.2f), 90f);
}
}
public static void hero(Layout l, float a, String name) {
TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get());
TextRenderer medium = MainMenuTextUtil.interMedium(bold);
HeroLayout.Sizes sz = l.sizes();
text(bold, I18n.get("screen.lovisual.hero_menu.greeting", name.toUpperCase()), l.heroX(), l.tagY(), sz.tag(), argb(Theme.theme().accent(), a));
text(bold, I18n.get("screen.lovisual.hero_menu.title1"), l.heroX(), l.titleY1(), sz.title(), argb(0xFFFFFF, a));
text(bold, I18n.get("screen.lovisual.hero_menu.title2"), l.heroX(), l.titleY2(), sz.title(), argb(0xFFFFFF, a));
text(medium, I18n.get("screen.lovisual.hero_menu.subtitle"), l.heroX(), l.subY(), sz.sub(), argb(0xFFFFFF, a * 0.65f));
}
public static void footer(Layout l, float a, String version) {
TextRenderer medium = MainMenuTextUtil.interMedium(MainMenuTextUtil.interBold(TextRenderer.get()));
int dim = argb(0xFFFFFF, a * 0.45f);
text(medium, I18n.get("screen.lovisual.hero_menu.disclaimer1"), l.heroX(), l.footerY(), l.sizes().footer(), dim);
text(medium, I18n.get("screen.lovisual.hero_menu.disclaimer2"), l.heroX(), l.footerY() + l.footerLine(), l.sizes().footer(), dim);
text(medium, I18n.get("screen.lovisual.hero_menu.version", version), l.heroX(), l.footerY() + l.footerLine() * 2f, l.sizes().footer(), argb(0xFFFFFF, a * 0.55f));
}
}

View file

@ -0,0 +1,57 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.paint;
import dev.loki.lovisual.features.gui.hud.config.HudRenderUtil;
import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.TextMetrics;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.svg.model.SvgRenderOptions;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.util.Mth;
/** Small colour and text helpers shared by the hero menu painters. */
public final class HeroPaint {
private HeroPaint() {
}
public static int argb(int rgb, float alpha) {
return (rgb & 0x00FFFFFF) | (Mth.clamp(Math.round(alpha * 255f), 0, 255) << 24);
}
public static int mix(int a, int b, float t) {
return HudRenderUtil.mixColor(a, b, t);
}
public static float easeOutCubic(float x) {
return 1f - (float) Math.pow(1f - Mth.clamp(x, 0f, 1f), 3f);
}
/** Draws text sized in logical pixels with the given weight renderer. */
public static void text(TextRenderer r, String text, float x, float y, float px, int argb) {
MainMenuTextUtil.drawText(r, text, x, y, HeroLayout.font(px), argb);
}
public static float width(TextRenderer r, String text, float px) {
return MainMenuTextUtil.measureWidth(r, text, HeroLayout.font(px));
}
public static float height(TextRenderer r, float px) {
return MainMenuTextUtil.measureHeight(r, HeroLayout.font(px));
}
/** Text vertically centred inside a box. */
public static void textCentered(TextRenderer r, String text, float x, float y, float h, float px, int argb) {
text(r, text, x, y + (h - height(r, px)) * 0.5f, px, argb);
}
/** Real line heights of the bold UI font, so the layout adapts to the actual glyph metrics. */
public static TextMetrics metrics() {
TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get());
return px -> height(bold, px);
}
/** Draws a tinted SVG icon centred on {@code (cx, cy)}. */
public static void icon(String svg, float cx, float cy, float size, int argb) {
Renderer2D.COLOR.svg(svg, cx - size * 0.5f, cy - size * 0.5f, size, size, SvgRenderOptions.overrideColor(argb));
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.gui.mainmenu.hero.paint;
import dev.loki.lovisual.features.gui.mainmenu.MainMenuTextUtil;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.DayCycle;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.HeroLayout.Layout;
import dev.loki.lovisual.features.gui.mainmenu.hero.logic.Rect;
import dev.loki.lovisual.features.theme.Themes;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.resources.language.I18n;
import java.time.LocalTime;
import java.util.List;
import static dev.loki.lovisual.features.gui.mainmenu.hero.paint.HeroPaint.*;
/** Top-right widget: clock, time-of-day label, theme dots and the daylight bar with its thumb. */
public final class HeroWidgetPainter {
private static final int[] BAR_STOPS = {0x283450, 0xDC8255, 0xEBA08C, 0x373C5F};
private HeroWidgetPainter() {
}
public static void draw(Layout l, float a, List<Themes.ThemeEntry> dots, String currentThemeId, int hoveredDot, LocalTime now) {
float k = l.k();
Rect w = l.widget();
float rad = 12f * k;
Renderer2D.COLOR.roundedRectSoftShadow(w.x(), w.y() + 2f * k, w.w(), w.h(), rad, 14f * k, 0.05f, argb(0x060810, a * 0.5f));
Renderer2D.COLOR.roundedRect(w.x(), w.y(), w.w(), w.h(), rad, argb(0x141820, a * 0.66f));
Renderer2D.COLOR.roundedRectStrokeGradient(w.x(), w.y(), w.w(), w.h(), rad, 1f, argb(0xFFFFFF, a * 0.12f), argb(0xFFFFFF, a * 0.06f), 90f);
TextRenderer bold = MainMenuTextUtil.interBold(TextRenderer.get());
TextRenderer medium = MainMenuTextUtil.interMedium(bold);
String time = String.format("%02d:%02d", now.getHour(), now.getMinute());
float cx = w.x() + l.pad() + 2f * k;
float cy = w.y() + l.pad();
text(bold, time, cx, cy, l.sizes().time(), argb(0xFFFFFF, a));
String period = I18n.get("screen.lovisual.hero_menu.period." + DayCycle.period(now.getHour()));
float periodY = cy + height(bold, l.sizes().time()) - height(medium, 8.5f * k) - 1.5f * k;
text(medium, period, cx + width(bold, time, l.sizes().time()) + 6f * k, periodY, 8.5f * k, argb(0xFFFFFF, a * 0.55f));
drawDots(l, a, dots, currentThemeId, hoveredDot);
drawBar(l, a, DayCycle.progress(now.getHour(), now.getMinute()));
}
private static void drawDots(Layout l, float a, List<Themes.ThemeEntry> dots, String currentId, int hovered) {
float r = l.dotRadius();
for (int i = 0; i < dots.size(); i++) {
Themes.ThemeEntry entry = dots.get(i);
float x = l.dotsStartX() + i * l.dotSpacing();
int color = entry.theme().accent();
boolean selected = entry.id().equals(currentId);
if (selected) {
Renderer2D.COLOR.circle(x, l.dotY(), r + 2.8f * l.k(), argb(color, a * 0.30f));
} else if (i == hovered) {
Renderer2D.COLOR.circle(x, l.dotY(), r + 1.8f * l.k(), argb(0xFFFFFF, a * 0.55f));
}
Renderer2D.COLOR.circle(x, l.dotY(), r, argb(color, a));
}
}
private static void drawBar(Layout l, float a, float progress) {
Rect b = l.bar();
float rad = b.h() * 0.5f;
float seg = b.w() / 3f;
for (int i = 0; i < 3; i++) {
Renderer2D.COLOR.quad(b.x() + i * seg, b.y(), seg, b.h(),
argb(BAR_STOPS[i], a * 0.9f), argb(BAR_STOPS[i + 1], a * 0.9f),
argb(BAR_STOPS[i + 1], a * 0.9f), argb(BAR_STOPS[i], a * 0.9f));
}
Renderer2D.COLOR.roundedRectStrokeGradient(b.x(), b.y(), b.w(), b.h(), rad, 0.8f * l.k(),
argb(0xFFFFFF, a * 0.14f), argb(0xFFFFFF, a * 0.14f), 0f);
float tx = DayCycle.thumbX(b.x(), b.w(), b.h(), progress);
float ty = b.y() + b.h() * 0.5f;
float tr = 4f * l.k();
Renderer2D.COLOR.circle(tx, ty, tr * 1.9f, argb(0xFFFFFF, a * 0.25f));
Renderer2D.COLOR.circle(tx, ty, tr, argb(0xFFFFFF, a));
}
}

View file

@ -0,0 +1,34 @@
package dev.loki.lovisual.features.gui.mainmenu;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.resources.language.I18n;
/** Caption pill that fades in under a hovered round main-menu button, so the glyph-only row stays readable. */
final class MainMenuHoverLabel {
private static final String[] KEYS = {"singleplayer", "multiplayer", "accounts", "addons", "options", "quit"};
private static final float TEXT_SIZE = 0.66f;
private MainMenuHoverLabel() {
}
/** @param buttonX left edge of button {@code index}; @param buttonY its top edge */
static void draw(LoVisualMainMenuScreen owner, int index, float buttonX, float buttonY, float opacity) {
float hover = owner.buttonHoverProgress[index];
if (hover < 0.02f || opacity < 0.02f) return;
float s = LoVisualMainMenuScreen.MENU_SCALE;
TextRenderer font = MainMenuTextUtil.interMedium(MainMenuTextUtil.interBold(TextRenderer.get()));
String text = I18n.get("screen.lovisual.hero_menu." + KEYS[index]);
float size = TEXT_SIZE * s;
float w = MainMenuTextUtil.measureWidth(font, text, size);
float h = MainMenuTextUtil.measureHeight(font, size);
float padX = 9f * s;
float padY = 4f * s;
float cx = buttonX + LoVisualMainMenuScreen.BUTTON_RADIUS;
float y = buttonY + LoVisualMainMenuScreen.BUTTON_SIZE + (8f + (1f - hover) * 4f) * s;
float alpha = hover * opacity;
Renderer2D.COLOR.roundedRect(cx - w * 0.5f - padX, y, w + padX * 2f, h + padY * 2f, (h + padY * 2f) * 0.5f,
MainMenuPainter.withAlpha(0x0C0F16, Math.round(alpha * 190f)));
MainMenuTextUtil.drawText(font, text, cx - w * 0.5f, y + padY, size, MainMenuPainter.withAlpha(0xFFFFFF, Math.round(alpha * 235f)));
}
}

View file

@ -104,6 +104,9 @@ final class MainMenuRenderer {
if (eased <= 0f) continue;
renderCircleButton(i, startX + i * (LoVisualMainMenuScreen.BUTTON_SIZE + LoVisualMainMenuScreen.BUTTON_SPACING), y, eased * opacity);
}
for (int i = 0; i < n; i++) {
MainMenuHoverLabel.draw(owner, i, startX + i * (LoVisualMainMenuScreen.BUTTON_SIZE + LoVisualMainMenuScreen.BUTTON_SPACING), y, opacity);
}
}
/** Liquid-glass circle + shadow + glyph for one button; {@code quit} tints red on hover. */

View file

@ -12,11 +12,11 @@ import dev.loki.lovisual.render.engine.color.RenderColor;
* Lives in the same package as {@link LoVisualMainMenuScreen}; kept as a separate
* class so the screen file stays within the two-hundred-line cap.
*/
final class MainMenuTextUtil {
public final class MainMenuTextUtil {
private MainMenuTextUtil() { }
static float measureWidth(TextRenderer renderer, String text, float size) {
public static float measureWidth(TextRenderer renderer, String text, float size) {
if (renderer == null || text == null) return 0f;
renderer.begin(size, true, false);
float width = (float) renderer.getWidth(text, false);
@ -24,7 +24,7 @@ final class MainMenuTextUtil {
return width;
}
static float measureHeight(TextRenderer renderer, float size) {
public static float measureHeight(TextRenderer renderer, float size) {
if (renderer == null) return 0f;
renderer.begin(size, true, false);
float height = (float) renderer.getHeight(false);
@ -32,7 +32,7 @@ final class MainMenuTextUtil {
return height;
}
static void drawText(TextRenderer renderer, String text, float x, float y, float size, int argb) {
public static void drawText(TextRenderer renderer, String text, float x, float y, float size, int argb) {
if (renderer == null || text == null || text.isEmpty()) return;
renderer.setAlpha(1.0);
renderer.begin(size, false, false);
@ -40,12 +40,12 @@ final class MainMenuTextUtil {
renderer.end();
}
static void drawCenteredText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) {
public static void drawCenteredText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) {
float x = centerX - measureWidth(renderer, text, size) * 0.5f;
drawText(renderer, text, x, y, size, argb);
}
static void drawLiquidGlassText(TextRenderer renderer, String text, float x, float y, float size, int argb) {
public static void drawLiquidGlassText(TextRenderer renderer, String text, float x, float y, float size, int argb) {
if (renderer == null || text == null || text.isEmpty()) return;
renderer.setAlpha(1.0);
renderer.begin(size, false, true);
@ -53,13 +53,13 @@ final class MainMenuTextUtil {
renderer.end();
}
static void drawCenteredLiquidGlassText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) {
public static void drawCenteredLiquidGlassText(TextRenderer renderer, String text, float centerX, float y, float size, int argb) {
float x = centerX - measureWidth(renderer, text, size) * 0.5f;
drawLiquidGlassText(renderer, text, x, y, size, argb);
}
/** Truncate {@code text} with a "..." suffix until it fits within {@code maxWidth} logical pixels. */
static String ellipsize(TextRenderer renderer, String text, float size, float maxWidth) {
public static String ellipsize(TextRenderer renderer, String text, float size, float maxWidth) {
if (text == null || text.isEmpty()) return "";
if (measureWidth(renderer, text, size) <= maxWidth) return text;
String out = text;
@ -70,11 +70,11 @@ final class MainMenuTextUtil {
}
/** Resolve the Inter Bold or Inter Medium {@link TextRenderer} for main-menu text, with a fallback chain. */
static TextRenderer interBold(TextRenderer fallback) { return Fonts.renderer("Inter", FontInfo.Type.Bold, fallback); }
static TextRenderer interMedium(TextRenderer fallback) { return Fonts.renderer("InterMedium", FontInfo.Type.Regular, fallback); }
static TextRenderer iconFont(TextRenderer fallback) { return Fonts.renderer("MainMenuIcons", FontInfo.Type.Regular, fallback); }
public static TextRenderer interBold(TextRenderer fallback) { return Fonts.renderer("Inter", FontInfo.Type.Bold, fallback); }
public static TextRenderer interMedium(TextRenderer fallback) { return Fonts.renderer("InterMedium", FontInfo.Type.Regular, fallback); }
public static TextRenderer iconFont(TextRenderer fallback) { return Fonts.renderer("MainMenuIcons", FontInfo.Type.Regular, fallback); }
/** Recompute the fixed logical viewport from the framebuffer size, matching {@link HudScale}. */
static float virtualWidth(int fbw, int fbh) { return Math.max(1f, HudScale.virtualWidth(fbw, fbh)); }
static float virtualHeight(int fbw, int fbh) { return Math.max(1f, HudScale.virtualHeight(fbw, fbh)); }
public static float virtualWidth(int fbw, int fbh) { return Math.max(1f, HudScale.virtualWidth(fbw, fbh)); }
public static float virtualHeight(int fbw, int fbh) { return Math.max(1f, HudScale.virtualHeight(fbw, fbh)); }
}

View file

@ -2,7 +2,6 @@ package dev.loki.lovisual.features.module.modules.combat.prediction;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import dev.loki.lovisual.config.common.CommonSettingSchemas;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
@ -12,10 +11,10 @@ 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.features.module.modules.combat.targetesp.TargetEspEffects;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes;
import dev.loki.lovisual.features.relations.category.CategoryService;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.features.module.modules.combat.targetesp.constellation.TargetEspConstellation;
import dev.loki.lovisual.features.module.modules.combat.targetesp.state.TargetEspColors;
import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.TargetEspSwords;
import dev.loki.lovisual.features.module.modules.combat.targetesp.state.TargetEspTracker;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.renderer.Renderer3DCullOptions;
@ -23,6 +22,7 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3DCulling;
import dev.loki.lovisual.util.target.TargetManager;
import java.util.function.IntFunction;
import java.util.function.Supplier;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderBlackhole;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects.renderGhosts;
@ -36,109 +36,92 @@ import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetE
@ModuleInfo(id = "targetesp", displayName = "TargetESP", aliases = {"target", "targetrender"}, category = ModuleCategory.COMBAT)
public class TargetESP extends Module {
private static final String SETTING_MODE = "mode";
private static final String SETTING_COLOR = "color";
private static final String SETTING_COLOR_MODE = "color_mode";
private static final String SETTING_COLOR_SPEED = "color_speed";
private static final String SETTING_COLOR_PHASE = "color_phase";
private static final String SETTING_COLOR_SPREAD = "color_spread";
private static final String SETTING_COLOR2 = "color2";
private static final String SETTING_USE_RELATION_COLORS = "use_relation_colors";
private static final String SETTING_RELATION_COLOR_MIX = "relation_color_mix";
private static final String SETTING_CROSSHAIR_TARGETS = "crosshair_targets";
private static final String SETTING_ALPHA_ANIMATION_SPEED = "alpha_animation_speed";
private static final String SETTING_ESP_LENGTH = "esp_length";
private static final String SETTING_ESP_FACTOR = "esp_factor";
private static final String SETTING_ESP_SHAKING = "esp_shaking";
private static final String SETTING_ESP_AMPLITUDE = "esp_amplitude";
private static final String SETTING_CAPTURE_SPIN_SPEED = "capture_spin_speed";
private static final String SETTING_CRYSTAL_HIT_RED = "crystal_hit_red";
private static final String SETTING_BLACKHOLE_SIZE = "blackhole_size";
private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed";
private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style";
private static final String SETTING_VORTEX_SPEED = "vortex_speed";
private static final float ALPHA_TICK_DT = 1.0f / 20.0f;
private static final float ALPHA_SNAP_EPSILON = 0.02f;
private final ModeValue espMode = modeSetting(
"targetEspMode",
SETTING_MODE,
"ghosts",
"ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex"
);
private final RGBAColorValue colorValue = color("targetEspColor", SETTING_COLOR, "#FFFF5555");
private final ModeValue colorMode = visibleWhen(modeSetting(
"targetEspColorMode",
SETTING_COLOR_MODE,
"Static",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"
), () -> true);
private final ModeValue espMode = modeSetting("targetEspMode", "mode", "ghosts",
"ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex", "constellation", "swords");
private final RGBAColorValue colorValue = color("targetEspColor", "color", "#FFFF5555");
private final ModeValue colorMode = visibleWhen(modeSetting("targetEspColorMode", "color_mode", "Static",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme"), () -> true);
private final NumberValue<Integer> colorSpeed =
visibleWhen(num("targetEspColorSpeed", SETTING_COLOR_SPEED, 18, 2, 54), () -> true);
private final NumberValue<Integer> colorPhase =
num("targetEspColorPhase", SETTING_COLOR_PHASE, 0, 0, 360);
private final NumberValue<Integer> colorSpread =
num("targetEspColorSpread", SETTING_COLOR_SPREAD, 90, -720, 720);
visibleWhen(num("targetEspColorSpeed", "color_speed", 18, 2, 54), () -> true);
private final NumberValue<Integer> colorPhase = num("targetEspColorPhase", "color_phase", 0, 0, 360);
private final NumberValue<Integer> colorSpread = num("targetEspColorSpread", "color_spread", 90, -720, 720);
private final RGBAColorValue colorValue2 =
visibleWhen(color("targetEspColor2", SETTING_COLOR2, "#FF55FFFF"), this::usesSecondaryColor);
private final BooleanValue useRelationColors =
boolCommon("targetEspUseRelationColors", SETTING_USE_RELATION_COLORS, CommonSettingSchemas.PLAYER_USE_RELATIONS, false);
private final NumberValue<Float> relationColorMix =
visibleWhen(num("targetEspRelationColorMix", SETTING_RELATION_COLOR_MIX, 1.0f, 0.0f, 1.0f), useRelationColors::get);
private final BooleanValue crosshairTargets =
bool("targetEspCrosshairTargets", SETTING_CROSSHAIR_TARGETS, true);
visibleWhen(color("targetEspColor2", "color2", "#FF55FFFF"), this::usesSecondaryColor);
private final BooleanValue useRelationColors = boolCommon("targetEspUseRelationColors", "use_relation_colors",
CommonSettingSchemas.PLAYER_USE_RELATIONS, false);
private final NumberValue<Float> relationColorMix = visibleWhen(
num("targetEspRelationColorMix", "relation_color_mix", 1.0f, 0.0f, 1.0f), useRelationColors::get);
private final BooleanValue crosshairTargets = bool("targetEspCrosshairTargets", "crosshair_targets", true);
private final NumberValue<Float> alphaAnimationSpeed =
num("targetEspAlphaAnimationSpeed", SETTING_ALPHA_ANIMATION_SPEED, 8.0f, 2.0f, 10.0f);
num("targetEspAlphaAnimationSpeed", "alpha_animation_speed", 8.0f, 2.0f, 10.0f);
private final NumberValue<Integer> espLength =
visibleWhen(num("targetEspLength", SETTING_ESP_LENGTH, 14, 1, 40), this::isGhostMode);
visibleWhen(num("targetEspLength", "esp_length", 14, 1, 40), onMode("ghosts"));
private final NumberValue<Integer> espFactor =
visibleWhen(num("targetEspFactor", SETTING_ESP_FACTOR, 8, 1, 20), this::isGhostMode);
visibleWhen(num("targetEspFactor", "esp_factor", 8, 1, 20), onMode("ghosts"));
private final NumberValue<Float> espShaking =
visibleWhen(num("targetEspShaking", SETTING_ESP_SHAKING, 1.8f, 1.5f, 10f), this::isGhostMode);
visibleWhen(num("targetEspShaking", "esp_shaking", 1.8f, 1.5f, 10f), onMode("ghosts"));
private final NumberValue<Float> espAmplitude =
visibleWhen(num("targetEspAmplitude", SETTING_ESP_AMPLITUDE, 3f, 0.1f, 8f), this::isGhostMode);
private final NumberValue<Float> captureSpinSpeed =
visibleWhen(num("targetEspCaptureSpinSpeed", SETTING_CAPTURE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isCaptureMarkMode);
visibleWhen(num("targetEspAmplitude", "esp_amplitude", 3f, 0.1f, 8f), onMode("ghosts"));
private final NumberValue<Float> captureSpinSpeed = visibleWhen(
num("targetEspCaptureSpinSpeed", "capture_spin_speed", 1.0f, 0.2f, 4.0f), onMode("capture_mark"));
private final BooleanValue crystalHitRed =
visibleWhen(bool("targetEspCrystalHitRed", SETTING_CRYSTAL_HIT_RED, true), this::isCrystalsMode);
private final NumberValue<Float> blackholeSize =
visibleWhen(num("targetEspBlackholeSize", SETTING_BLACKHOLE_SIZE, 1.1f, 0.5f, 3.0f), this::isBlackholeMode);
private final NumberValue<Float> blackholeSpinSpeed =
visibleWhen(num("targetEspBlackholeSpinSpeed", SETTING_BLACKHOLE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isBlackholeMode);
private final ModeValue blackholeStyle =
visibleWhen(modeSetting("targetEspBlackholeStyle", SETTING_BLACKHOLE_STYLE, "Single", "Single", "Body"), this::isBlackholeMode);
visibleWhen(bool("targetEspCrystalHitRed", "crystal_hit_red", true), onMode("crystals"));
private final NumberValue<Float> blackholeSize = visibleWhen(
num("targetEspBlackholeSize", "blackhole_size", 1.1f, 0.5f, 3.0f), onMode("blackhole"));
private final NumberValue<Float> blackholeSpinSpeed = visibleWhen(
num("targetEspBlackholeSpinSpeed", "blackhole_spin_speed", 1.0f, 0.2f, 4.0f), onMode("blackhole"));
private final ModeValue blackholeStyle = visibleWhen(
modeSetting("targetEspBlackholeStyle", "blackhole_style", "Single", "Single", "Body"), onMode("blackhole"));
private final NumberValue<Float> vortexSpeed =
visibleWhen(num("targetEspVortexSpeed", SETTING_VORTEX_SPEED, 1.2f, 0.2f, 4.0f), this::isVortexMode);
visibleWhen(num("targetEspVortexSpeed", "vortex_speed", 1.2f, 0.2f, 4.0f), onMode("vortex"));
private final NumberValue<Integer> constellationStars = visibleWhen(
num("targetEspConstellationStars", "constellation_stars", 9, 3, 24), onMode("constellation"));
private final NumberValue<Float> constellationSpeed = visibleWhen(
num("targetEspConstellationSpeed", "constellation_speed", 1.0f, 0.2f, 4.0f), onMode("constellation"));
private final NumberValue<Float> constellationOrbit = visibleWhen(
num("targetEspConstellationOrbit", "constellation_orbit", 1.1f, 0.5f, 2.5f), onMode("constellation"));
private final BooleanValue constellationLines = visibleWhen(
bool("targetEspConstellationLines", "constellation_lines", true), onMode("constellation"));
private final NumberValue<Float> constellationPointSize = visibleWhen(
num("targetEspConstellationPointSize", "constellation_point_size", 0.12f, 0.05f, 0.4f), onMode("constellation"));
private final NumberValue<Float> constellationGlow = visibleWhen(
num("targetEspConstellationGlow", "constellation_glow", 1.0f, 0.0f, 2.0f), onMode("constellation"));
private final NumberValue<Integer> swordsSpeed = visibleWhen(
num("targetEspSwordsSpeed", "swords_speed", 2200, 600, 8000), onMode("swords"));
private final NumberValue<Float> swordsRadius = visibleWhen(
num("targetEspSwordsRadius", "swords_radius", 1.0f, 0.5f, 2.5f), onMode("swords"));
private final NumberValue<Float> swordsSize = visibleWhen(
num("targetEspSwordsSize", "swords_size", 1.0f, 0.3f, 2.5f), onMode("swords"));
private final NumberValue<Float> swordsBob = visibleWhen(
num("targetEspSwordsBob", "swords_bob", 0.06f, 0.0f, 0.4f), onMode("swords"));
private final BooleanValue swordsGlow = visibleWhen(bool("targetEspSwordsGlow", "swords_glow", true), onMode("swords"));
private final BooleanValue swordsPhysics =
visibleWhen(bool("targetEspSwordsPhysics", "swords_physics", true), onMode("swords"));
private final TargetEspColors colors = new TargetEspColors(colorMode, colorSpeed, colorPhase, colorSpread,
colorValue, colorValue2, useRelationColors, relationColorMix);
private final TargetEspTracker tracker = new TargetEspTracker();
private final TargetEspCrystalRenderer crystalRenderer = new TargetEspCrystalRenderer();
private LivingEntity target;
private LivingEntity renderTarget;
private float targetAlpha;
private final TargetEspSwords swords = new TargetEspSwords();
@Override
public void onDisable() {
target = null;
renderTarget = null;
targetAlpha = 0.0f;
tracker.reset();
crystalRenderer.clear();
swords.reset();
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null) {
target = null;
updateRenderTarget(null);
crystalRenderer.clear();
return;
}
target = TargetManager.getTarget(crosshairTargets.get());
updateRenderTarget(target);
if (renderTarget == null) {
LivingEntity target = isEnabled() && mc.level != null ? TargetManager.getTarget(crosshairTargets.get()) : null;
if (tracker.update(target, alphaAnimationSpeed.get()) || tracker.renderTarget() == null) {
crystalRenderer.clear();
swords.reset();
}
if (!isEnabled() || mc.level == null) return;
setCaptureSpeedScale(isCaptureMarkMode() ? captureSpinSpeed.get() : 1.0f);
if (renderTarget != null && targetAlpha > 0.0f) {
setCaptureSpeedScale(espMode.get().equals("capture_mark") ? captureSpinSpeed.get() : 1.0f);
if (tracker.isVisible()) {
tickTargetEsp();
}
}
@ -150,153 +133,46 @@ public class TargetESP extends Module {
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
if (!isEnabled() || renderTarget == null || targetAlpha <= 0.001f) return;
Renderer3DCullOptions cull = resolveCullOptions(renderTarget);
if (!Renderer3DCulling.shouldRender(renderTarget, tickDelta, cull)) return;
LivingEntity target = tracker.renderTarget();
if (!isEnabled() || !tracker.isVisible()) return;
if (!Renderer3DCulling.shouldRender(target, tickDelta, resolveCullOptions(target))) return;
IntFunction<Integer> palette = index -> AnimatedRenderColors.scaleAlpha(getColorArgb(index), targetAlpha);
String mode = espMode.get();
if ("ring".equals(mode)) {
drawOldTargetEsp(renderer, renderTarget, tickDelta, palette);
} else if ("ghosts".equals(mode)) {
renderGhosts(
renderer,
espLength.get(),
espFactor.get(),
espShaking.get(),
espAmplitude.get(),
renderTarget,
tickDelta,
palette
);
} else if ("capture_mark".equals(mode)) {
renderCaptureMark(renderer, renderTarget, tickDelta, palette);
} else if ("crystals".equals(mode)) {
crystalRenderer.render(renderer, renderTarget, tickDelta, crystalHitRed.get(), targetAlpha, this::getColorArgb);
} else if ("blackhole".equals(mode)) {
renderBlackhole(renderer, renderTarget, tickDelta, palette,
blackholeSize.get(), blackholeSpinSpeed.get(), targetAlpha, blackholeStyle.get());
} else if ("vortex".equals(mode)) {
renderVortex(renderer, renderTarget, tickDelta, palette, vortexSpeed.get());
}
}
private void updateRenderTarget(LivingEntity next) {
float dt = Math.max(AnimationUtility.deltaTime(), ALPHA_TICK_DT);
float speed = alphaAnimationSpeed.get();
if (next != null) {
if (renderTarget == null) {
renderTarget = next;
targetAlpha = AnimationUtility.snap(
AnimationUtility.approach(targetAlpha, 1.0f, dt, speed),
1.0f,
ALPHA_SNAP_EPSILON
);
return;
}
if (renderTarget != next) {
renderTarget = next;
targetAlpha = 1.0f;
crystalRenderer.clear();
return;
}
targetAlpha = AnimationUtility.snap(
AnimationUtility.approach(targetAlpha, 1.0f, dt, speed),
1.0f,
ALPHA_SNAP_EPSILON
);
return;
}
targetAlpha = AnimationUtility.snap(
AnimationUtility.approach(targetAlpha, 0.0f, dt, speed),
0.0f,
ALPHA_SNAP_EPSILON
);
if (targetAlpha <= 0.0f) {
renderTarget = null;
crystalRenderer.clear();
float alpha = tracker.alpha();
IntFunction<Integer> palette = index -> AnimatedRenderColors.scaleAlpha(getColorArgb(index), alpha);
switch (espMode.get()) {
case "ring" -> drawOldTargetEsp(renderer, target, tickDelta, palette);
case "ghosts" -> renderGhosts(renderer, espLength.get(), espFactor.get(), espShaking.get(),
espAmplitude.get(), target, tickDelta, palette);
case "capture_mark" -> renderCaptureMark(renderer, target, tickDelta, palette);
case "crystals" -> crystalRenderer.render(renderer, target, tickDelta, crystalHitRed.get(), alpha, this::getColorArgb);
case "blackhole" -> renderBlackhole(renderer, target, tickDelta, palette,
blackholeSize.get(), blackholeSpinSpeed.get(), alpha, blackholeStyle.get());
case "vortex" -> renderVortex(renderer, target, tickDelta, palette, vortexSpeed.get());
case "constellation" -> TargetEspConstellation.render(renderer, target, tickDelta, palette,
constellationStars.get(), constellationSpeed.get(), constellationOrbit.get(),
constellationLines.get(), constellationPointSize.get(), constellationGlow.get());
case "swords" -> swords.render(renderer, target, tickDelta, palette, swordsSpeed.get(), swordsRadius.get(),
swordsSize.get(), swordsBob.get(), swordsGlow.get(), swordsPhysics.get());
default -> { }
}
}
// Raycast-selected targets may be behind walls: fall back to a look-based frustum check.
private Renderer3DCullOptions resolveCullOptions(LivingEntity target) {
Minecraft mc = Minecraft.getInstance();
if (target == null || mc.player == null) {
return Renderer3DCullOptions.SMART_TARGET;
}
// Allow rendering through walls if the current target is already selected,
// so raycast-based targeting doesn't get culled by line-of-sight checks.
if (!mc.player.hasLineOfSight(target)) {
return Renderer3DCullOptions.FRUSTUM_LOOK;
}
return Renderer3DCullOptions.SMART_TARGET;
boolean visible = mc.player == null || mc.player.hasLineOfSight(target);
return visible ? Renderer3DCullOptions.SMART_TARGET : Renderer3DCullOptions.FRUSTUM_LOOK;
}
private boolean isGhostMode() {
return "ghosts".equals(espMode.get());
}
private boolean isCaptureMarkMode() {
return "capture_mark".equals(espMode.get());
}
private boolean isCrystalsMode() {
return "crystals".equals(espMode.get());
}
private boolean isBlackholeMode() {
return "blackhole".equals(espMode.get());
}
private boolean isVortexMode() {
return "vortex".equals(espMode.get());
private Supplier<Boolean> onMode(String mode) {
return () -> mode.equals(espMode.get());
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode());
return colors.usesSecondary();
}
private int getColorArgb(int count) {
int phase = colorPhase.get() + Math.round(count * colorSpread.get() / 90.0f);
int animated = AnimatedRenderColors.resolve(
animatedColorMode(),
colorSpeed.get(),
phase,
colorValue.getArgb(),
colorValue2.getArgb(),
true
);
if (!useRelationColors.get() || renderTarget == null) {
return animated;
}
int relation = relationColor(renderTarget, animated);
relation = AnimatedRenderColors.withAlpha(relation, (animated >>> 24) & 0xFF);
return AnimatedRenderColors.mixArgb(animated, relation, relationColorMix.get());
}
private int relationColor(LivingEntity entity, int fallback) {
if (entity instanceof Player player) {
return CategoryService.getColor(player);
}
String name = entity.getName() != null ? entity.getName().getString() : null;
if (name == null || name.isBlank()) {
return fallback;
}
return CategoryService.getColor(name);
}
private AnimatedRenderColors.Mode animatedColorMode() {
return switch (colorMode.get()) {
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;
};
return colors.argb(count, tracker.renderTarget());
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.constellation;
/**
* Pure geometry of the "constellation" TargetESP mode: stars on a tilted,
* slightly wobbling orbit around the target centre. Offsets are relative to
* the target's feet position.
*/
public final class ConstellationLayout {
private static final double TAU = Math.PI * 2.0;
private ConstellationLayout() {
}
/**
* @param out 3 * stars slots, filled with x, y, z per star
* @param orbit orbit radius in target widths
* @param time seconds
* @param speed spin multiplier
*/
public static void positions(double[] out, int stars, float width, float height,
float orbit, double time, float speed) {
double spin = time * speed * 1.5;
for (int s = 0; s < stars; s++) {
double frac = (double) s / stars;
double base = frac * TAU;
double incl = Math.PI * 0.35 * Math.sin(frac * Math.PI * 1.5 + 0.5);
double ang = spin + base;
double r = orbit * width * (0.85 + 0.15 * Math.sin(base * 2.3));
out[s * 3] = Math.cos(ang) * r;
out[s * 3 + 1] = height / 2.0
+ Math.sin(ang) * Math.sin(incl) * r * 0.7
+ Math.sin(time * 1.2 + frac * TAU) * 0.2;
out[s * 3 + 2] = Math.sin(ang) * Math.cos(incl) * r;
}
}
/** Star brightness pulse in [0.5, 1.0]. */
public static float pulse(double time, int star, int stars) {
double frac = (double) star / stars;
return (float) (0.75 + 0.25 * Math.sin(time * 3.0 + frac * TAU * 1.7));
}
}

View file

@ -0,0 +1,88 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.constellation;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import java.util.function.IntFunction;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*;
/** "Constellation" TargetESP mode: glowing stars orbiting the target, joined by thin lines. */
public final class TargetEspConstellation {
private static final float LINE_ALPHA = 120.0f / 255.0f;
private static final float GLOW_SCALE = 3.5f;
private static final float GLOW_ALPHA = 0.3f;
private TargetEspConstellation() {
}
public static void render(Renderer3D renderer, LivingEntity target, float tickDelta,
IntFunction<Integer> palette, int stars, float speed, float orbit,
boolean lines, float pointSize, float glow) {
if (renderer == null || target == null || stars < 2) return;
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double time = (GameClock.millis() % 100000L) / 1000.0;
double[] p = new double[stars * 3];
ConstellationLayout.positions(p, stars, target.getBbWidth(), target.getBbHeight(), orbit, time, speed);
if (lines) {
drawLines(renderer, pos, p, stars, palette);
}
drawStars(renderer, pos, p, stars, time, palette, pointSize, glow);
}
private static void drawLines(Renderer3D renderer, Vec3 pos, double[] p, int stars,
IntFunction<Integer> palette) {
float prevWidth = RenderState.lineWidth;
RenderState.lineWidth = 1.2f;
MeshBuilder mesh;
try {
mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
} finally {
RenderState.lineWidth = prevWidth;
}
if (mesh == null) return;
for (int s = 0; s < stars; s++) {
int n = (s + 1) % stars;
int color = applyOpacity(palette.apply((int) (360.0 * s / stars)), LINE_ALPHA);
mesh.ensureLineCapacity();
int a = addVertex(mesh, pos.x + p[s * 3], pos.y + p[s * 3 + 1], pos.z + p[s * 3 + 2], color);
int b = addVertex(mesh, pos.x + p[n * 3], pos.y + p[n * 3 + 1], pos.z + p[n * 3 + 2], color);
mesh.line(a, b);
}
}
private static void drawStars(Renderer3D renderer, Vec3 pos, double[] p, int stars, double time,
IntFunction<Integer> palette, float pointSize, float glow) {
MeshBuilder core = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
TextureStorage.PARTICLE_STARS, Renderer3D.DepthMode.MAIN);
MeshBuilder halo = glow > 0.01f
? renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
TextureStorage.BLOOM, Renderer3D.DepthMode.MAIN)
: null;
if (core == null) return;
for (int s = 0; s < stars; s++) {
float pulse = ConstellationLayout.pulse(time, s, stars);
float alpha = 0.7f + 0.3f * pulse;
int color = palette.apply((int) (360.0 * s / stars));
double x = pos.x + p[s * 3];
double y = pos.y + p[s * 3 + 1];
double z = pos.z + p[s * 3 + 2];
float size = pointSize * pulse;
addBillboardQuad(core, x, y, z, size, applyOpacity(color, alpha));
if (halo != null) {
addBillboardQuad(halo, x, y, z, size * GLOW_SCALE, applyOpacity(color, alpha * glow * GLOW_ALPHA));
}
}
}
}

View file

@ -0,0 +1,75 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.state;
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.relations.category.CategoryService;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
/** Resolves TargetESP colors from the module's color settings, optionally mixed with relation colors. */
public final class TargetEspColors {
private final ModeValue mode;
private final NumberValue<Integer> speed;
private final NumberValue<Integer> phase;
private final NumberValue<Integer> spread;
private final RGBAColorValue primary;
private final RGBAColorValue secondary;
private final BooleanValue useRelations;
private final NumberValue<Float> relationMix;
public TargetEspColors(ModeValue mode, NumberValue<Integer> speed, NumberValue<Integer> phase,
NumberValue<Integer> spread, RGBAColorValue primary, RGBAColorValue secondary,
BooleanValue useRelations, NumberValue<Float> relationMix) {
this.mode = mode;
this.speed = speed;
this.phase = phase;
this.spread = spread;
this.primary = primary;
this.secondary = secondary;
this.useRelations = useRelations;
this.relationMix = relationMix;
}
public boolean usesSecondary() {
return AnimatedRenderColors.usesSecondary(animatedMode());
}
/** @param target entity whose relation color is mixed in, or null to skip relations */
public int argb(int count, LivingEntity target) {
int shifted = phase.get() + Math.round(count * spread.get() / 90.0f);
int animated = AnimatedRenderColors.resolve(
animatedMode(), speed.get(), shifted, primary.getArgb(), secondary.getArgb(), true);
if (!useRelations.get() || target == null) {
return animated;
}
int relation = AnimatedRenderColors.withAlpha(relationColor(target, animated), (animated >>> 24) & 0xFF);
return AnimatedRenderColors.mixArgb(animated, relation, relationMix.get());
}
private int relationColor(LivingEntity entity, int fallback) {
if (entity instanceof Player player) {
return CategoryService.getColor(player);
}
String name = entity.getName() != null ? entity.getName().getString() : null;
if (name == null || name.isBlank()) {
return fallback;
}
return CategoryService.getColor(name);
}
private AnimatedRenderColors.Mode animatedMode() {
return switch (mode.get()) {
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;
};
}
}

View file

@ -0,0 +1,53 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.state;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import net.minecraft.world.entity.LivingEntity;
/** Follows the current TargetESP target and fades its alpha in and out. */
public final class TargetEspTracker {
private static final float TICK_DT = 1.0f / 20.0f;
private static final float SNAP_EPSILON = 0.02f;
private LivingEntity renderTarget;
private float alpha;
public LivingEntity renderTarget() {
return renderTarget;
}
public float alpha() {
return alpha;
}
public boolean isVisible() {
return renderTarget != null && alpha > 0.001f;
}
public void reset() {
renderTarget = null;
alpha = 0.0f;
}
/**
* @return true when the render target switched or vanished, so per-target
* caches must be dropped
*/
public boolean update(LivingEntity next, float animationSpeed) {
float dt = Math.max(AnimationUtility.deltaTime(), TICK_DT);
if (next == null) {
alpha = approach(0.0f, dt, animationSpeed);
if (alpha > 0.0f) return false;
boolean had = renderTarget != null;
renderTarget = null;
return had;
}
boolean switched = renderTarget != null && renderTarget != next;
renderTarget = next;
alpha = switched ? 1.0f : approach(1.0f, dt, animationSpeed);
return switched;
}
private float approach(float to, float dt, float speed) {
return AnimationUtility.snap(AnimationUtility.approach(alpha, to, dt, speed), to, SNAP_EPSILON);
}
}

View file

@ -0,0 +1,101 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.sword;
import java.util.Random;
/**
* Spring physics of the "swords" TargetESP mode: every sword is pulled towards its slot on an
* orbit around the target, gets kicked outwards when the target is hurt, and leans into its
* motion. Pure math, no Minecraft types.
*/
final class SwordPhysics {
static final int COUNT = 8;
private static final double SPRING = 0.22;
private static final double DAMPING = 0.78;
private SwordPhysics() {
}
static final class Sword {
double x, y, z;
double vx, vy, vz;
float roll;
float pitch;
}
/** Frame inputs; angles in radians, {@code step} = frame time in 1/60 s units clamped to [0.2, 2]. */
record Frame(double targetX, double targetZ, double centerY, double radius, double orbitAngle,
double timeMs, double bobAmplitude, float step, boolean kick, boolean physics,
double cameraYawRad) {
}
/** Places every sword on its orbit slot at rest. */
static void place(Sword[] swords, double targetX, double targetZ, double centerY, double radius, double orbitAngle) {
for (int i = 0; i < swords.length; i++) {
Sword s = swords[i];
double a = orbitAngle + i * (Math.PI * 2.0 / swords.length);
s.x = targetX + Math.cos(a) * radius;
s.y = centerY;
s.z = targetZ + Math.sin(a) * radius;
s.vx = 0;
s.vy = 0;
s.vz = 0;
s.roll = 0;
s.pitch = 0;
}
}
static void step(Sword[] swords, Frame f, Random rnd) {
for (int i = 0; i < swords.length; i++) {
Sword s = swords[i];
double a = f.orbitAngle() + i * (Math.PI * 2.0 / swords.length);
double bob = Math.sin(f.timeMs() * 0.0035 + i * (Math.PI / 4.0)) * f.bobAmplitude();
double idealX = f.targetX() + Math.cos(a) * f.radius();
double idealY = f.centerY() + bob;
double idealZ = f.targetZ() + Math.sin(a) * f.radius();
if (f.kick()) kick(s, f, rnd);
if (!f.physics()) {
s.x = idealX;
s.y = idealY;
s.z = idealZ;
s.vx = 0;
s.vy = 0;
s.vz = 0;
s.roll = 0;
s.pitch = 0;
continue;
}
double spring = SPRING * f.step();
double damping = Math.pow(DAMPING, f.step());
s.vx = (s.vx + (idealX - s.x) * spring) * damping;
s.vy = (s.vy + (idealY - s.y) * spring) * damping;
s.vz = (s.vz + (idealZ - s.z) * spring) * damping;
s.x += s.vx * f.step();
s.y += s.vy * f.step();
s.z += s.vz * f.step();
lean(s, f);
}
}
private static void kick(Sword s, Frame f, Random rnd) {
double dx = s.x - f.targetX();
double dz = s.z - f.targetZ();
double len = Math.hypot(dx, dz);
if (len > 0.001) {
double power = 0.16 + rnd.nextDouble() * 0.06;
s.vx += dx / len * power;
s.vz += dz / len * power;
s.vy += 0.07;
}
s.roll += (float) ((rnd.nextDouble() - 0.5) * 28.0);
}
/** Roll follows sideways speed as seen from the camera, pitch follows vertical speed. */
private static void lean(Sword s, Frame f) {
double sideSpeed = -s.vx * Math.cos(f.cameraYawRad()) - s.vz * Math.sin(f.cameraYawRad());
float targetRoll = (float) Math.max(-42.0, Math.min(42.0, sideSpeed * 130.0));
float targetPitch = (float) Math.max(-32.0, Math.min(32.0, -s.vy * 85.0));
float k = Math.min(1f, 0.25f * f.step());
s.roll += (targetRoll - s.roll) * k;
s.pitch += (targetPitch - s.pitch) * k;
}
}

View file

@ -0,0 +1,107 @@
package dev.loki.lovisual.features.module.modules.combat.targetesp.sword;
import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Frame;
import dev.loki.lovisual.features.module.modules.combat.targetesp.sword.SwordPhysics.Sword;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.Random;
import java.util.function.IntFunction;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity;
/** "Swords" TargetESP mode: eight glowing swords orbit the target and recoil when it takes a hit. */
public final class TargetEspSwords {
private static final int HURT_COLOR = 0xFFFF4178;
private static final float GLOW_SCALE = 1.35f;
private static final float GLOW_ALPHA = 0.45f;
private final Sword[] swords = new Sword[SwordPhysics.COUNT];
private final Random random = new Random();
private LivingEntity lastTarget;
private int lastHurt;
private long lastMs;
public TargetEspSwords() {
for (int i = 0; i < swords.length; i++) {
swords[i] = new Sword();
}
}
public void reset() {
lastTarget = null;
lastHurt = 0;
lastMs = 0L;
}
/**
* @param speedMs milliseconds per orbit
* @param radius orbit radius multiplier
* @param size sword size multiplier
* @param bob vertical bobbing amplitude in blocks
*/
public void render(Renderer3D renderer, LivingEntity target, float tickDelta, IntFunction<Integer> palette,
long speedMs, float radius, float size, float bob, boolean glow, boolean physics) {
if (renderer == null || target == null) return;
long now = GameClock.millis();
float dt = lastMs == 0L ? 0f : Math.min(0.1f, (now - lastMs) / 1000f);
lastMs = now;
float step = Math.max(0.2f, Math.min(2.0f, dt * 60f));
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
double centerY = pos.y + target.getBbHeight() * 0.45;
double orbitRadius = (target.getBbWidth() + 0.35) * radius;
long period = Math.max(200L, speedMs);
double orbit = (double) (now % period) / period * Math.PI * 2.0;
if (lastTarget != target) {
lastTarget = target;
lastHurt = target.hurtTime;
SwordPhysics.place(swords, pos.x, pos.z, centerY, orbitRadius, orbit);
}
int hurt = target.hurtTime;
boolean kick = hurt > lastHurt && hurt >= 9;
lastHurt = hurt;
float hurtPc = hurt > 0 ? (float) Math.sin(hurt * (Math.PI / 10.0)) : 0f;
float yawRad = (float) Math.toRadians(net.minecraft.client.Minecraft.getInstance().gameRenderer.mainCamera().yRot());
SwordPhysics.step(swords, new Frame(pos.x, pos.z, centerY, orbitRadius, orbit, now, bob, step, kick,
physics, yawRad), random);
float halfW = 0.20f * size;
float halfH = 0.40f * size;
if (glow) {
MeshBuilder halo = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
TextureStorage.BLOOM, Renderer3D.DepthMode.NONE);
if (halo != null) draw(halo, palette, hurtPc, halfW * GLOW_SCALE, halfH * GLOW_SCALE, GLOW_ALPHA * (1f + hurtPc * 0.65f));
}
MeshBuilder core = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, TextureStorage.SWORD,
Renderer3D.DepthMode.NONE);
if (core != null) draw(core, palette, hurtPc, halfW, halfH, 1f);
}
private void draw(MeshBuilder mesh, IntFunction<Integer> palette, float hurtPc, float halfW, float halfH, float alpha) {
for (int i = 0; i < swords.length; i++) {
Sword s = swords[i];
int base = AnimatedRenderColors.mixArgb(palette.apply(i * 45), HURT_COLOR, hurtPc);
int color = applyOpacity(base, alpha);
Quaternionf rot = new Quaternionf(RenderState.cameraRotation)
.rotateZ((float) Math.toRadians(s.roll))
.rotateX((float) Math.toRadians(s.pitch));
Vector3f right = new Vector3f(1, 0, 0).rotate(rot).mul(halfW);
Vector3f up = new Vector3f(0, 1, 0).rotate(rot).mul(halfH);
addBillboardVertices(mesh, s.x, s.y, s.z, right, up, color, color, color, color);
}
}
}

View file

@ -0,0 +1,100 @@
package dev.loki.lovisual.features.module.modules.misc.voice;
import dev.loki.lovisual.compat.voicechat.VoiceChatBridge;
import dev.loki.lovisual.compat.voicechat.VoiceState;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
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.math.RenderMath;
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.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
/** Simple Voice Chat companion: pulsing indicators above players who are talking. */
@ModuleInfo(id = "voicechat", displayName = "VoiceChat", category = ModuleCategory.MISC)
public class VoiceChat extends Module {
private static final float HEAD_OFFSET = 0.55f;
private static final int MAX_SPEAKERS = 64;
private final NumberValue<Integer> holdMs = num("voiceChatHoldMs", "hold_ms", 350, 100, 1500);
private final NumberValue<Float> maxDistance = num("voiceChatMaxDistance", "max_distance", 32.0f, 8.0f, 96.0f);
private final NumberValue<Float> indicatorSize = num("voiceChatIndicatorSize", "indicator_size", 1.0f, 0.5f, 2.5f);
private final BooleanValue pulse = bool("voiceChatPulse", "pulse", true);
private final BooleanValue showSelf = bool("voiceChatShowSelf", "show_self", true);
private final BooleanValue showMuted = visibleWhen(bool("voiceChatShowMuted", "show_muted", true), showSelf::get);
private final RGBAColorValue color = color("voiceChatColor", "color", "#FF55FF77");
private final RGBAColorValue whisperColor = color("voiceChatWhisperColor", "whisper_color", "#FFFFD255");
private final RGBAColorValue selfColor =
visibleWhen(color("voiceChatSelfColor", "self_color", "#FF55AAFF"), showSelf::get);
private final RGBAColorValue mutedColor = visibleWhen(
color("voiceChatMutedColor", "muted_color", "#FFFF5555"), () -> showSelf.get() && showMuted.get());
@Override
protected String getUnavailableReason() {
return VoiceChatBridge.isInstalled() ? "" : "Simple Voice Chat is not installed";
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null || !VoiceChatBridge.isInstalled()) return;
MeshBuilder rings = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, Renderer3D.DepthMode.MAIN);
MeshBuilder halo = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
TextureStorage.BLOOM, Renderer3D.DepthMode.MAIN);
if (rings == null || halo == null) return;
VoiceState state = VoiceChatBridge.state();
long hold = holdMs.get();
double time = (GameClock.millis() % 100000L) / 1000.0;
float size = indicatorSize.get();
double maxSq = (double) maxDistance.get() * maxDistance.get();
for (VoiceState.Speaker speaker : state.speakers(hold, MAX_SPEAKERS)) {
Player player = mc.level.getPlayerByUUID(speaker.id());
if (player == null || player == mc.player) continue;
Vec3 head = headPosition(player, tickDelta);
if (head.distanceToSqr(mc.player.position()) > maxSq) continue;
int argb = speaker.whisper() ? whisperColor.getArgb() : color.getArgb();
VoiceIndicatorRenderer.speaking(rings, halo, head, size, state.level(speaker.id(), hold), argb, time, pulse.get());
}
if (showSelf.get() && !mc.options.getCameraType().isFirstPerson()) {
renderSelf(mc.player, state, rings, halo, hold, time, size, tickDelta);
}
}
private void renderSelf(Player self, VoiceState state, MeshBuilder rings, MeshBuilder halo,
long hold, double time, float size, float tickDelta) {
Vec3 head = headPosition(self, tickDelta);
float level = state.selfLevel(hold);
if (level > 0f) {
int argb = state.isSelfWhispering(hold) ? whisperColor.getArgb() : selfColor.getArgb();
VoiceIndicatorRenderer.speaking(rings, halo, head, size, level, argb, time, pulse.get());
}
VoiceState.MicStatus mic = state.micStatus(hold);
boolean silenced = mic == VoiceState.MicStatus.MUTED || mic == VoiceState.MicStatus.DISABLED;
if (showMuted.get() && silenced) {
VoiceIndicatorRenderer.muted(rings, head, size * 0.45f, mutedColor.getArgb());
}
}
private static Vec3 headPosition(Player player, float tickDelta) {
Vec3 pos = RenderMath.getLerpedPos(player, tickDelta);
return new Vec3(pos.x, pos.y + player.getBbHeight() + HEAD_OFFSET, pos.z);
}
}

View file

@ -0,0 +1,20 @@
package dev.loki.lovisual.features.module.modules.misc.voice;
/** Pure animation of the expanding rings drawn above a talking player. */
final class VoiceIndicatorPulse {
static final int RINGS = 2;
private static final double PULSE_HZ = 0.9;
private VoiceIndicatorPulse() {
}
/** @param radius ring radius as a fraction of the indicator size; @param alpha opacity in [0, 1] */
record Ring(float radius, float alpha) { }
static Ring ring(double timeSec, int index, boolean pulse) {
double offset = (double) index / RINGS;
double phase = pulse ? (timeSec * PULSE_HZ + offset) % 1.0 : 0.35 + 0.3 * offset;
float p = (float) phase;
return new Ring(0.45f + 0.75f * p, pulse ? 1.0f - p : 0.6f - 0.25f * (float) offset);
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.lovisual.features.module.modules.misc.voice;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3f;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.*;
/** Draws the billboard glyphs of {@link VoiceChat}: a glowing halo with rings, and a crossed-out mic mark. */
final class VoiceIndicatorRenderer {
private static final int RING_SEGMENTS = 24;
private static final float RING_WIDTH = 0.045f;
private static final float CROSS_WIDTH = 0.05f;
private VoiceIndicatorRenderer() {
}
/** @param ringMesh untextured additive mesh; @param haloMesh soft glow texture mesh */
static void speaking(MeshBuilder ringMesh, MeshBuilder haloMesh, Vec3 at, float size, float level,
int argb, double timeSec, boolean pulse) {
addBillboardQuad(haloMesh, at.x, at.y, at.z, size * 0.9f, applyOpacity(argb, level * 0.55f));
Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation);
Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation);
for (int i = 0; i < VoiceIndicatorPulse.RINGS; i++) {
VoiceIndicatorPulse.Ring ring = VoiceIndicatorPulse.ring(timeSec, i, pulse);
ring(ringMesh, at, right, up, size * ring.radius(), applyOpacity(argb, level * ring.alpha()));
}
}
/** Red cross, used over your own head when the microphone is muted or disabled. */
static void muted(MeshBuilder mesh, Vec3 at, float size, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation).mul(size * 0.5f);
Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation).mul(size * 0.5f);
Vector3f normal = new Vector3f(right).cross(up).normalize();
bar(mesh, at, new Vector3f(right).add(up), new Vector3f(right).sub(up), normal, size, argb);
bar(mesh, at, new Vector3f(right).sub(up), new Vector3f(right).add(up), normal, size, argb);
}
private static void ring(MeshBuilder mesh, Vec3 at, Vector3f right, Vector3f up, float radius, int argb) {
float inner = Math.max(0f, radius - RING_WIDTH);
for (int i = 0; i < RING_SEGMENTS; i++) {
double a1 = i * Math.PI * 2.0 / RING_SEGMENTS;
double a2 = (i + 1) * Math.PI * 2.0 / RING_SEGMENTS;
mesh.ensureQuadCapacity();
int v1 = point(mesh, at, right, up, a1, radius, argb);
int v2 = point(mesh, at, right, up, a2, radius, argb);
int v3 = point(mesh, at, right, up, a2, inner, argb);
int v4 = point(mesh, at, right, up, a1, inner, argb);
mesh.quad(v1, v2, v3, v4);
}
}
private static int point(MeshBuilder mesh, Vec3 at, Vector3f right, Vector3f up, double angle, float r, int argb) {
float x = (float) Math.cos(angle) * r;
float y = (float) Math.sin(angle) * r;
return addVertex(mesh, at.x + right.x * x + up.x * y, at.y + right.y * x + up.y * y,
at.z + right.z * x + up.z * y, argb);
}
/** Thin camera-facing quad along {@code from -> to} (offsets from {@code at}). */
private static void bar(MeshBuilder mesh, Vec3 at, Vector3f from, Vector3f to,
Vector3f normal, float size, int argb) {
Vector3f side = new Vector3f(to).sub(from).cross(normal);
if (side.lengthSquared() < 1.0E-8f) return;
side.normalize(CROSS_WIDTH * size);
mesh.ensureQuadCapacity();
int v1 = addVertex(mesh, at.x + from.x - side.x, at.y + from.y - side.y, at.z + from.z - side.z, argb);
int v2 = addVertex(mesh, at.x + from.x + side.x, at.y + from.y + side.y, at.z + from.z + side.z, argb);
int v3 = addVertex(mesh, at.x + to.x + side.x, at.y + to.y + side.y, at.z + to.z + side.z, argb);
int v4 = addVertex(mesh, at.x + to.x - side.x, at.y + to.y - side.y, at.z + to.z - side.z, argb);
mesh.quad(v1, v2, v3, v4);
}
}

View file

@ -8,6 +8,7 @@ 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.phase.WorldPhase;
import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -25,39 +26,46 @@ import java.util.Map;
//todo Description
@ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS)
public class KillEffect extends Module {
private static final String SETTING_MODE = "mode";
private static final String SETTING_Y_SPEED = "y_speed";
private static final String SETTING_PLAY_SOUND = "play_sound";
private static final String SETTING_SOUND_VOLUME = "sound_volume";
private static final String SETTING_COLOR = "color";
private static final String SETTING_MOBS = "mobs";
private static final String SETTING_BEAM_INTENSITY = "beam_intensity";
private static final String SETTING_BEAM_SIZE = "beam_size";
private static final Identifier ORTHODOX_SOUND =
Identifier.fromNamespaceAndPath("lovisual", "sounds/misc/orthodox.wav");
private final ModeValue mode = modeSetting("killEffectMode", SETTING_MODE, "Orthodox",
"Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado");
private final ModeValue mode = modeSetting("killEffectMode", "mode", "Orthodox",
"Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood");
private final NumberValue<Integer> ySpeed = visibleWhen(
num("killEffectYSpeed", SETTING_Y_SPEED, 0, -10, 10), () -> isMode("Orthodox"));
num("killEffectYSpeed", "y_speed", 0, -10, 10), () -> isMode("Orthodox"));
private final BooleanValue playSound = visibleWhen(
bool("killEffectPlaySound", SETTING_PLAY_SOUND, true), () -> isMode("Orthodox"));
bool("killEffectPlaySound", "play_sound", true), () -> isMode("Orthodox"));
private final NumberValue<Double> soundVolume = visibleWhen(
num("killEffectSoundVolume", SETTING_SOUND_VOLUME, 1.0, 0.0, 2.0),
num("killEffectSoundVolume", "sound_volume", 1.0, 0.0, 2.0),
() -> isMode("Orthodox") && playSound.get());
private final RGBAColorValue color = visibleWhen(
color("killEffectColor", SETTING_COLOR, "#FFFF9600"), this::usesColorSetting);
private final BooleanValue mobs = bool("killEffectMobs", SETTING_MOBS, false);
color("killEffectColor", "color", "#FFFF9600"), this::usesColorSetting);
private final BooleanValue mobs = bool("killEffectMobs", "mobs", false);
private final NumberValue<Float> beamIntensity = visibleWhen(
num("killEffectBeamIntensity", SETTING_BEAM_INTENSITY, 1.2f, 0.2f, 3.0f), this::usesBeamSettings);
num("killEffectBeamIntensity", "beam_intensity", 1.2f, 0.2f, 3.0f), this::usesBeamSettings);
private final NumberValue<Float> beamSize = visibleWhen(
num("killEffectBeamSize", SETTING_BEAM_SIZE, 2.0f, 0.5f, 5.0f), this::usesBeamSettings);
num("killEffectBeamSize", "beam_size", 2.0f, 0.5f, 5.0f), this::usesBeamSettings);
private final RGBAColorValue bloodColor = visibleWhen(
color("killEffectBloodColor", "blood_color", "#FF8A0303"), () -> isMode("Blood"));
private final ModeValue bloodStyle = visibleWhen(
modeSetting("killEffectBloodStyle", "blood_style", "Burst", "Burst", "Fountain", "Ring", "Spray"),
() -> isMode("Blood"));
private final NumberValue<Integer> bloodAmount = visibleWhen(
num("killEffectBloodAmount", "blood_amount", 70, 10, 250), () -> isMode("Blood"));
private final NumberValue<Float> bloodPower = visibleWhen(
num("killEffectBloodPower", "blood_power", 1.0f, 0.3f, 2.5f), () -> isMode("Blood"));
private final NumberValue<Float> bloodSize = visibleWhen(
num("killEffectBloodSize", "blood_size", 1.0f, 0.3f, 3.0f), () -> isMode("Blood"));
private final NumberValue<Float> bloodTime = visibleWhen(
num("killEffectBloodTime", "blood_time", 6.0f, 1.0f, 30.0f), () -> isMode("Blood"));
private final Map<Integer, Long> handled = new HashMap<>();
private final KillOrthodoxHelper orthodoxHelper = new KillOrthodoxHelper();
private final KillEmberHelper emberHelper = new KillEmberHelper();
private final KillLightningHelper lightningHelper = new KillLightningHelper();
private final KillBeamHelper beamHelper = new KillBeamHelper();
private final BloodKillHelper bloodHelper = new BloodKillHelper();
private boolean isMode(String value) {
return value.equals(mode.get());
@ -107,6 +115,7 @@ public class KillEffect extends Module {
beamHelper.tick();
lightningHelper.tick();
orthodoxHelper.tick(now);
bloodHelper.tick(mc.level, mc.player, bloodTime.get());
}
private void handleKill(Vec3 pos, LivingEntity entity, long now) {
@ -126,6 +135,11 @@ 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 ("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),
bloodAmount.get(), bloodPower.get(), bloodSize.get(), bloodColor.getArgb(),
bloodStyle.get(), pos.subtract(Minecraft.getInstance().player.position()));
}
}
@ -136,6 +150,7 @@ public class KillEffect extends Module {
emberHelper.clear();
lightningHelper.clear();
beamHelper.clear();
bloodHelper.clear();
}
@Override
@ -153,6 +168,7 @@ public class KillEffect extends Module {
emberHelper.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) {

View file

@ -0,0 +1,121 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addVertex;
/** Builds the triangles of a teardrop (drop) and a ragged puddle (splat), plus color helpers. */
final class BloodGeometry {
private static final int RING = 6;
private static final int SPLAT_SEGMENTS = 14;
private BloodGeometry() {
}
/** Teardrop: a 6-sided ring with a front cap and a speed-stretched, fading tail. */
static void drop(MeshBuilder mesh, Vec3 p, Drop d) {
double speed = d.vel.length();
Vec3 dir = speed < 1.0E-4 ? new Vec3(0, -1, 0) : d.vel.scale(1.0 / speed);
float r = d.size * 0.5f;
float head = r * 0.9f;
float tail = r * (1.6f + (float) Math.min(speed, 0.9) * 5.5f);
Vec3 up = Math.abs(dir.y) > 0.95 ? new Vec3(1, 0, 0) : new Vec3(0, 1, 0);
Vec3 s1 = dir.cross(up).normalize();
Vec3 s2 = dir.cross(s1).normalize();
Vec3 front = p.add(dir.scale(head));
Vec3 back = p.subtract(dir.scale(tail));
Vec3 center = p.add(dir.scale(head * 0.15));
for (int i = 0; i < RING; i++) {
int j = (i + 1) % RING;
Vec3 n1 = ringNormal(s1, s2, i);
Vec3 n2 = ringNormal(s1, s2, j);
Vec3 n = n1.add(n2).normalize();
int cf = lit(d.color, n.add(dir.scale(0.6)).normalize(), d.light, true);
int cb = lit(d.color, n.add(dir.scale(-0.3)).normalize(), d.light, false);
tri(mesh, front, center.add(n1.scale(r)), center.add(n2.scale(r)), cf, cf, cf);
tri(mesh, center.add(n1.scale(r)), back, center.add(n2.scale(r)), cb, withAlpha(shade(cb, 0.8f), 150), cb);
}
}
/** Puddle: grows quickly, then fades and darkens as it dries. Returns false when finished. */
static boolean splat(MeshBuilder mesh, Splat s, long nowMs, float lifeMs) {
long age = nowMs - s.startMs();
float life = age / lifeMs;
float fade = 1.0f - smooth((life - 0.7f) / 0.3f);
if (life >= 1.0f || fade <= 0.01f) return false;
float grow = 1.0f - (float) Math.pow(1.0f - Math.min(1f, age / 260f), 3);
float dry = smooth(life / 0.8f);
float radius = s.radius() * (0.35f + 0.65f * grow);
int base = shade(s.color(), s.light() * (1.0f - 0.45f * dry));
int centerCol = withAlpha(shade(base, 0.72f), Math.round(235 * fade));
int edgeCol = withAlpha(base, Math.round(205 * fade));
int rimCol = withAlpha(base, 0);
Vec3 c = new Vec3(s.pos().x, s.pos().y + 0.006 + s.layer() * 0.0004, s.pos().z);
for (int i = 0; i < SPLAT_SEGMENTS; i++) {
int j = (i + 1) % SPLAT_SEGMENTS;
float r1 = radius * (0.72f + noise(s.seed(), i) * 0.5f);
float r2 = radius * (0.72f + noise(s.seed(), j) * 0.5f);
double a1 = i * Math.PI * 2.0 / SPLAT_SEGMENTS;
double a2 = j * Math.PI * 2.0 / SPLAT_SEGMENTS;
Vec3 p1 = c.add(Math.cos(a1) * r1, 0, Math.sin(a1) * r1);
Vec3 p2 = c.add(Math.cos(a2) * r2, 0, Math.sin(a2) * r2);
Vec3 o1 = c.add(Math.cos(a1) * r1 * 1.18, 0, Math.sin(a1) * r1 * 1.18);
Vec3 o2 = c.add(Math.cos(a2) * r2 * 1.18, 0, Math.sin(a2) * r2 * 1.18);
tri(mesh, c, p1, p2, centerCol, edgeCol, edgeCol);
tri(mesh, p1, o1, o2, edgeCol, rimCol, rimCol);
tri(mesh, p1, o2, p2, edgeCol, rimCol, edgeCol);
}
return true;
}
private static Vec3 ringNormal(Vec3 s1, Vec3 s2, int i) {
double a = i * Math.PI * 2.0 / RING;
return s1.scale(Math.cos(a)).add(s2.scale(Math.sin(a)));
}
private static void tri(MeshBuilder m, Vec3 a, Vec3 b, Vec3 c, int ca, int cb, int cc) {
m.ensureTriCapacity();
m.triangle(addVertex(m, a.x, a.y, a.z, ca), addVertex(m, b.x, b.y, b.z, cb), addVertex(m, c.x, c.y, c.z, cc));
}
private static int lit(int color, Vec3 normal, float light, boolean highlight) {
double dot = Math.max(0.0, normal.x * 0.35 + normal.y * 0.85 + normal.z * 0.25);
int c = shade(color, (float) (0.45 + 0.75 * dot) * light);
return highlight && dot > 0.8 ? mix(c, 0xFFFF9A9A, (float) ((dot - 0.8) / 0.2) * 0.45f * light) : c;
}
static int shade(int color, float k) {
int r = Math.min(255, Math.round(((color >> 16) & 255) * k));
int g = Math.min(255, Math.round(((color >> 8) & 255) * k));
int b = Math.min(255, Math.round((color & 255) * k));
return (color & 0xFF000000) | (r << 16) | (g << 8) | b;
}
static int mix(int first, int second, float t) {
float k = Math.max(0f, Math.min(1f, t));
int r = Math.round(((first >> 16) & 255) * (1 - k) + ((second >> 16) & 255) * k);
int g = Math.round(((first >> 8) & 255) * (1 - k) + ((second >> 8) & 255) * k);
int b = Math.round((first & 255) * (1 - k) + (second & 255) * k);
return (first & 0xFF000000) | (r << 16) | (g << 8) | b;
}
static int withAlpha(int color, int alpha) {
return (Math.max(0, Math.min(255, alpha)) << 24) | (color & 0x00FFFFFF);
}
static float smooth(float v) {
float t = Math.max(0f, Math.min(1f, v));
return t * t * (3f - 2f * t);
}
/** Deterministic per-puddle noise in [0, 1]. */
static float noise(long seed, int salt) {
long v = seed + 0x9E3779B97F4A7C15L * (salt + 1L);
v = (v ^ (v >>> 30)) * 0xBF58476D1CE4E5B9L;
v = (v ^ (v >>> 27)) * 0x94D049BB133111EBL;
return ((v ^ (v >>> 31)) >>> 40) / 16777215.0f;
}
}

View file

@ -0,0 +1,104 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat;
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 dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.ClipContext;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraft.world.phys.Vec3;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/** KillEffect "Blood" mode: a burst of physically simulated drops that stain the floor. */
public final class BloodKillHelper {
private static final int MAX_DROPS = 900;
private static final int MAX_SPLATS = 700;
private static final double RANGE_SQ = 96.0 * 96.0;
private final Random random = new Random();
private final List<Drop> drops = new ArrayList<>();
private final List<Splat> splats = new ArrayList<>();
/** @param style BURST, FOUNTAIN, RING or SPRAY (case-insensitive); {@code away} points attacker to victim */
public void spawn(Vec3 center, float width, float height, int count, float power, float scale, int color,
String style, Vec3 away) {
Vec3 flat = new Vec3(away.x, 0, away.z);
Vec3 dir = flat.lengthSqr() < 1.0E-6 ? new Vec3(1, 0, 0) : flat.normalize();
BloodPhysics.burst(drops, center, width, height, count, power, scale, color,
BloodPhysics.Style.of(style), dir, random);
trim(drops, MAX_DROPS);
}
public void tick(ClientLevel level, Entity viewer, float splatSeconds) {
long now = GameClock.millis();
long life = (long) (splatSeconds * 1000f);
splats.removeIf(s -> now - s.startMs() > life);
if (drops.isEmpty()) return;
BloodPhysics.Surface surface = surface(level, viewer);
List<Drop> children = new ArrayList<>();
drops.removeIf(d -> !BloodPhysics.step(d, surface, splats, children, now, random));
drops.addAll(children);
trim(drops, MAX_DROPS);
trim(splats, MAX_SPLATS);
}
public void render(Renderer3D renderer, Vec3 camera, float tickDelta, float splatSeconds) {
if (renderer == null || (drops.isEmpty() && splats.isEmpty())) return;
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
long now = GameClock.millis();
float lifeMs = splatSeconds * 1000f;
for (Splat s : splats) {
if (s.pos().distanceToSqr(camera) <= RANGE_SQ) {
BloodGeometry.splat(mesh, s, now, lifeMs);
}
}
float t = Math.max(0f, Math.min(1f, tickDelta));
for (Drop d : drops) {
Vec3 p = d.prev.add(d.pos.subtract(d.prev).scale(t));
if (p.distanceToSqr(camera) <= RANGE_SQ) {
BloodGeometry.drop(mesh, p, d);
}
}
}
public void clear() {
drops.clear();
splats.clear();
}
private static BloodPhysics.Surface surface(ClientLevel level, Entity viewer) {
return new BloodPhysics.Surface() {
@Override
public BloodPhysics.Hit cast(Vec3 from, Vec3 to) {
BlockHitResult hit = level.clip(new ClipContext(from, to,
ClipContext.Block.COLLIDER, ClipContext.Fluid.ANY, viewer));
if (hit == null || hit.getType() != HitResult.Type.BLOCK) return null;
return new BloodPhysics.Hit(hit.getLocation(), hit.getDirection().getStepX(),
hit.getDirection().getStepY(), hit.getDirection().getStepZ());
}
@Override
public float light(Vec3 pos) {
return 0.3f + 0.7f * (level.getMaxLocalRawBrightness(BlockPos.containing(pos)) / 15.0f);
}
};
}
private static void trim(List<?> list, int max) {
if (list.size() > max) {
list.subList(0, list.size() - max).clear();
}
}
}

View file

@ -0,0 +1,34 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood;
import net.minecraft.world.phys.Vec3;
/** Mutable blood drop in flight and immutable floor splat. */
final class BloodParticles {
private BloodParticles() {
}
static final class Drop {
Vec3 pos;
Vec3 prev;
Vec3 vel;
final float size;
final int color;
final boolean child;
final float scale;
float light = 1.0f;
int age;
Drop(Vec3 pos, Vec3 vel, float size, int color, float scale, boolean child) {
this.pos = pos;
this.prev = pos;
this.vel = vel;
this.size = size;
this.color = color | 0xFF000000;
this.scale = scale;
this.child = child;
}
}
record Splat(Vec3 pos, float radius, int color, long startMs, long seed, float light, int layer) {
}
}

View file

@ -0,0 +1,129 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Drop;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodParticles.Splat;
import net.minecraft.world.phys.Vec3;
import java.util.List;
import java.util.Random;
/** Drop physics: ballistic flight, wall bounces, floor splats and secondary droplets. */
final class BloodPhysics {
static final int MAX_FLIGHT_TICKS = 120;
private static final double GRAVITY = 0.045;
private static final double DRAG = 0.985;
private BloodPhysics() {
}
record Hit(Vec3 at, int stepX, int stepY, int stepZ) {
}
/** World access, kept abstract so the physics stays testable. */
interface Surface {
/** First solid block hit on the segment, or null. */
Hit cast(Vec3 from, Vec3 to);
/** Brightness multiplier in [0.3, 1]. */
float light(Vec3 pos);
}
/** Shape of the initial blood eruption. */
enum Style {
BURST, FOUNTAIN, RING, SPRAY;
static Style of(String name) {
for (Style s : values()) {
if (s.name().equalsIgnoreCase(name)) return s;
}
return BURST;
}
}
/**
* @param away horizontal unit vector pointing from the attacker to the victim, used by SPRAY
*/
static void burst(List<Drop> out, Vec3 center, float width, float height, int count, float power,
float scale, int color, Style style, Vec3 away, Random rnd) {
for (int i = 0; i < count; i++) {
Vec3 origin = center.add((rnd.nextDouble() - 0.5) * width * 0.6,
(rnd.nextDouble() - 0.5) * height * 0.5, (rnd.nextDouble() - 0.5) * width * 0.6);
float size = (float) (0.025 + Math.pow(rnd.nextDouble(), 2.2) * 0.075) * scale;
int shade = BloodGeometry.shade(color, 0.82f + rnd.nextFloat() * 0.3f);
out.add(new Drop(origin, velocity(style, power, away, rnd), size, shade, scale, false));
}
}
private static Vec3 velocity(Style style, double power, Vec3 away, Random rnd) {
double angle = rnd.nextDouble() * Math.PI * 2.0;
double cos = Math.cos(angle);
double sin = Math.sin(angle);
return switch (style) {
case FOUNTAIN -> {
double h = (0.02 + rnd.nextDouble() * 0.07) * power;
yield new Vec3(cos * h, (0.25 + rnd.nextDouble() * 0.35) * power * 1.6, sin * h);
}
case RING -> {
double h = (0.18 + rnd.nextDouble() * 0.12) * power;
yield new Vec3(cos * h, (0.04 + rnd.nextDouble() * 0.05) * power, sin * h);
}
case SPRAY -> {
double forward = (0.12 + rnd.nextDouble() * 0.25) * power;
double side = (rnd.nextDouble() - 0.5) * 0.35 * power;
yield new Vec3(away.x * forward - away.z * side, (0.05 + rnd.nextDouble() * 0.2) * power,
away.z * forward + away.x * side);
}
default -> {
double h = (0.05 + rnd.nextDouble() * 0.24) * power;
yield new Vec3(cos * h, (0.06 + rnd.nextDouble() * 0.34) * power, sin * h);
}
};
}
/** Advances one tick; returns false once the drop is spent (landed or expired). */
static boolean step(Drop d, Surface surface, List<Splat> splats, List<Drop> children, long nowMs, Random rnd) {
d.prev = d.pos;
if (++d.age > MAX_FLIGHT_TICKS) return false;
Vec3 vel = new Vec3(d.vel.x * DRAG, (d.vel.y - GRAVITY) * DRAG, d.vel.z * DRAG);
Vec3 next = d.pos.add(vel);
Hit hit = surface.cast(d.pos, next);
if (hit == null) {
d.pos = next;
d.vel = vel;
if ((d.age & 3) == 0) d.light = surface.light(next);
return true;
}
if (hit.stepY() > 0) {
land(d, hit.at(), vel, surface, splats, children, nowMs, rnd);
return false;
}
d.vel = bounce(hit, vel);
d.pos = hit.at().add(hit.stepX() * 0.01, hit.stepY() * 0.01, hit.stepZ() * 0.01);
return true;
}
private static Vec3 bounce(Hit hit, Vec3 vel) {
if (hit.stepY() < 0) {
return new Vec3(vel.x * 0.4, -Math.abs(vel.y) * 0.1, vel.z * 0.4);
}
double vx = hit.stepX() != 0 ? -vel.x * 0.12 : vel.x * 0.3;
double vz = hit.stepZ() != 0 ? -vel.z * 0.12 : vel.z * 0.3;
return new Vec3(vx, Math.min(vel.y, 0.0) * 0.5, vz);
}
private static void land(Drop d, Vec3 at, Vec3 vel, Surface surface, List<Splat> splats,
List<Drop> children, long nowMs, Random rnd) {
float speed = (float) vel.length();
float radius = d.size * (2.2f + Math.min(speed, 0.8f) * 4.0f);
splats.add(new Splat(at, radius, d.color, nowMs, rnd.nextLong(), surface.light(at), rnd.nextInt(40)));
if (d.child || d.size <= 0.055f * d.scale || speed <= 0.25f) return;
int n = 2 + rnd.nextInt(3);
for (int k = 0; k < n; k++) {
double a = rnd.nextDouble() * Math.PI * 2.0;
double h = 0.03 + rnd.nextDouble() * 0.07;
Vec3 v = new Vec3(Math.cos(a) * h + vel.x * 0.25, 0.05 + rnd.nextDouble() * 0.08, Math.sin(a) * h + vel.z * 0.25);
children.add(new Drop(at.add(0, 0.02, 0), v, d.size * 0.35f, d.color, d.scale, true));
}
}
}

View file

@ -1,7 +1,9 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held;
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.modules.visuals.items.held.lua.HandScripts;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
@ -29,6 +31,11 @@ import net.minecraft.world.InteractionHand;
public final class HoldMyItems extends Module {
private final HandAnimState handAnim = new HandAnimState();
private HandScripts scripts;
/** Dynamics: Java-динамика; Scripts: Lua из config/lovisual/holdmyitems; Both: оба подряд. */
public final ModeValue handMode =
modeSetting("holdMyItemsMode", "mode", "Dynamics", "Dynamics", "Scripts", "Both");
public final NumberValue<Float> swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f);
public final NumberValue<Float> easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f);
@ -48,6 +55,20 @@ 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);
public boolean dynamicsActive() {
return !"Scripts".equals(handMode.get());
}
public boolean scriptsActive() {
return !"Dynamics".equals(handMode.get());
}
/** Рантайм Lua-скриптов; создаётся лениво, чтобы LuaJ не грузился в режиме Dynamics. */
public HandScripts scripts() {
if (scripts == null) scripts = new HandScripts();
return scripts;
}
public HandAnimState handAnim() {
return handAnim;
}
@ -71,6 +92,7 @@ public final class HoldMyItems extends Module {
@Override
public void onEnable() {
handAnim.reset();
if (scripts != null) scripts.reload();
}
/** Перекладывает линейный equip-прогресс через ease-out-back с настройками модуля. */

View file

@ -0,0 +1,18 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
/** Контекст кадра руки, который видят Lua-скрипты (как `context` в HoldMyItems). */
public record HandFrame(
Player player,
ItemStack stack,
boolean mainHand,
boolean rightArm,
float swingProgress,
float equipProgress,
float tickDelta,
PoseStack pose
) {
}

View file

@ -0,0 +1,175 @@
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.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.runtime.LuaSandbox;
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;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import org.luaj.vm2.Globals;
import org.luaj.vm2.LuaTable;
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 раз в
* секунду), иначе встроенный пример из ресурсов мода. Ошибка скрипта пишется в лог один раз и
* отключает только этот скрипт до правки файла.
*/
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 float[][] lastState = new float[2][2];
private long lastCheck;
private Player player;
private ItemStack stack = ItemStack.EMPTY;
public HandScripts() {
Globals g = sandbox.globals();
g.set("M", pose.table());
g.set("P", EntityApi.player(() -> player));
g.set("I", EntityApi.item(() -> stack));
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);
}
/** Применяет hand_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));
return true;
}
/** Применяет item_pose к матрице предмета; 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);
ms.pushPose();
scripts.runItem(new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms));
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) {
refresh();
ScriptSlot slot = slots[index];
if (slot == null || !slot.canRun()) return;
this.player = f.player();
this.stack = f.stack();
Globals g = sandbox.globals();
g.set("registry", slot.registryTable());
g.set("mainHand", LuaValue.valueOf(f.mainHand()));
g.set("rightArm", LuaValue.valueOf(f.rightArm()));
g.set("swingProgress", LuaValue.valueOf(f.swingProgress()));
g.set("equipProgress", LuaValue.valueOf(f.equipProgress()));
g.set("tickDelta", LuaValue.valueOf(f.tickDelta()));
g.set("usingItem", LuaValue.valueOf(f.player().isUsingItem()));
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);
}
} catch (IOException e) {
LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage());
return null;
}
}
}

View file

@ -0,0 +1,68 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api;
import java.util.function.DoubleUnaryOperator;
/** Функции плавности для Lua-скриптов (`Easings:easeOutBack(t)`), t в [0,1]. */
public final class Easings {
private static final double C1 = 1.70158;
private static final double C3 = C1 + 1.0;
private Easings() {
}
public static double outQuad(double t) {
return 1 - (1 - t) * (1 - t);
}
public static double inOutCubic(double t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
public static double outCubic(double t) {
return 1 - Math.pow(1 - t, 3);
}
public static double inOutSine(double t) {
return -(Math.cos(Math.PI * t) - 1) / 2;
}
public static double outBack(double t) {
return 1 + C3 * Math.pow(t - 1, 3) + C1 * Math.pow(t - 1, 2);
}
public static double inOutBack(double t) {
double c2 = C1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
}
public static double outElastic(double t) {
if (t <= 0 || t >= 1) return Math.max(0, Math.min(1, t));
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1;
}
public static double outBounce(double t) {
double n = 7.5625, d = 2.75;
if (t < 1 / d) return n * t * t;
if (t < 2 / d) { t -= 1.5 / d; return n * t * t + 0.75; }
if (t < 2.5 / d) { t -= 2.25 / d; return n * t * t + 0.9375; }
t -= 2.625 / d;
return n * t * t + 0.984375;
}
/** Имя функции в Lua и сама функция. */
public static java.util.Map<String, DoubleUnaryOperator> all() {
java.util.Map<String, DoubleUnaryOperator> m = new java.util.LinkedHashMap<>();
m.put("easeOutQuad", Easings::outQuad);
m.put("easeInOutCubic", Easings::inOutCubic);
m.put("easeOutCubic", Easings::outCubic);
m.put("easeInOutSine", Easings::inOutSine);
m.put("easeOutBack", Easings::outBack);
m.put("easeInOutBack", Easings::inOutBack);
m.put("easeOutElastic", Easings::outElastic);
m.put("easeOutBounce", Easings::outBounce);
return m;
}
}

View file

@ -0,0 +1,64 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.tags.ItemTags;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ItemUseAnimation;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
import java.util.function.Supplier;
/** Lua-глобалы {@code P} (игрок) и {@code I} (предмет текущей руки), только чтение. */
public final class EntityApi {
private EntityApi() {
}
public static LuaTable player(Supplier<Player> p) {
LuaTable t = new LuaTable();
LuaTables.put(t, "getSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().horizontalDistance()));
LuaTables.put(t, "getXSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().x));
LuaTables.put(t, "getYSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().y));
LuaTables.put(t, "getZSpeed", a -> LuaValue.valueOf(p.get().getDeltaMovement().z));
LuaTables.put(t, "getYaw", a -> LuaValue.valueOf(p.get().getYRot()));
LuaTables.put(t, "getPitch", a -> LuaValue.valueOf(p.get().getXRot()));
LuaTables.put(t, "getAge", a -> LuaValue.valueOf(p.get().tickCount));
LuaTables.put(t, "getHealth", a -> LuaValue.valueOf(p.get().getHealth()));
LuaTables.put(t, "isOnGround", a -> LuaValue.valueOf(p.get().onGround()));
LuaTables.put(t, "isSneaking", a -> LuaValue.valueOf(p.get().isShiftKeyDown()));
LuaTables.put(t, "isSprinting", a -> LuaValue.valueOf(p.get().isSprinting()));
LuaTables.put(t, "isSwimming", a -> LuaValue.valueOf(p.get().isSwimming()));
LuaTables.put(t, "isUsingItem", a -> LuaValue.valueOf(p.get().isUsingItem()));
LuaTables.put(t, "isTouchingWater", a -> LuaValue.valueOf(p.get().isInWater()));
LuaTables.put(t, "isClimbing", a -> LuaValue.valueOf(p.get().onClimbable()));
LuaTables.put(t, "hasVehicle", a -> LuaValue.valueOf(p.get().isPassenger()));
return t;
}
public static LuaTable item(Supplier<ItemStack> s) {
LuaTable t = new LuaTable();
LuaTables.put(t, "isEmpty", a -> LuaValue.valueOf(s.get().isEmpty()));
LuaTables.put(t, "isOf", a -> LuaValue.valueOf(id(s.get()).equals(withNamespace(a.arg(2).tojstring()))));
LuaTables.put(t, "getName", a -> LuaValue.valueOf(id(s.get())));
LuaTables.put(t, "isBlock", a -> LuaValue.valueOf(s.get().getItem() instanceof net.minecraft.world.item.BlockItem));
LuaTables.put(t, "isEnchanted", a -> LuaValue.valueOf(s.get().isEnchanted()));
LuaTables.put(t, "isSword", a -> LuaValue.valueOf(s.get().is(ItemTags.SWORDS)));
LuaTables.put(t, "getUseAction", a -> LuaValue.valueOf(useAction(s.get())));
return t;
}
static String id(ItemStack stack) {
return BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
}
static String withNamespace(String name) {
return name.indexOf(':') >= 0 ? name : "minecraft:" + name;
}
private static String useAction(ItemStack stack) {
ItemUseAnimation anim = stack.getUseAnimation();
return anim.name().toLowerCase(java.util.Locale.ROOT);
}
}

View file

@ -0,0 +1,32 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
import org.luaj.vm2.Varargs;
import org.luaj.vm2.lib.VarArgFunction;
/** Сборка Lua-таблиц из Java-лямбд. Вызовы `T:name(x)` приходят с self в arg(1). */
public final class LuaTables {
@FunctionalInterface
public interface Fn {
LuaValue call(Varargs args);
}
private LuaTables() {
}
public static void put(LuaTable table, String name, Fn fn) {
table.set(name, new VarArgFunction() {
@Override
public Varargs invoke(Varargs args) {
return fn.call(args);
}
});
}
/** Число-аргумент по индексу (учитывая self в arg(1)); отсутствует = def. */
public static double num(Varargs a, int i, double def) {
return a.arg(i).isnil() ? def : a.arg(i).todouble();
}
}

View file

@ -0,0 +1,89 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
/**
* Lua-глобал {@code M}: преобразования матрицы руки/предмета (moveX/Y/Z, rotateX/Y/Z с pivot,
* scale, push/pop) и математика. PoseStack подставляется на каждый вызов скрипта; лишние push
* закрываются {@link #finish()}, лишние pop игнорируются.
*/
public final class PoseApi {
private PoseStack stack;
private int depth;
public PoseApi bind(PoseStack stack) {
this.stack = stack;
this.depth = 0;
return this;
}
/** Закрывает push, оставленные скриптом незакрытыми. */
public void finish() {
while (stack != null && depth > 0) {
stack.popPose();
depth--;
}
stack = null;
}
private void rotate(Axis axis, double deg, double px, double py, double pz) {
if (stack == null) return;
stack.translate((float) px, (float) py, (float) pz);
stack.mulPose(axis.rotationDegrees((float) deg));
stack.translate((float) -px, (float) -py, (float) -pz);
}
public LuaTable table() {
LuaTable t = new LuaTable();
LuaTables.put(t, "moveX", a -> move(LuaTables.num(a, 2, 0), 0, 0));
LuaTables.put(t, "moveY", a -> move(0, LuaTables.num(a, 2, 0), 0));
LuaTables.put(t, "moveZ", a -> move(0, 0, LuaTables.num(a, 2, 0)));
LuaTables.put(t, "translate", a -> move(LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 0), LuaTables.num(a, 4, 0)));
LuaTables.put(t, "rotateX", a -> rot(Axis.XP, a));
LuaTables.put(t, "rotateY", a -> rot(Axis.YP, a));
LuaTables.put(t, "rotateZ", a -> rot(Axis.ZP, a));
LuaTables.put(t, "scale", a -> {
if (stack != null) {
double x = LuaTables.num(a, 2, 1);
stack.scale((float) x, (float) LuaTables.num(a, 3, x), (float) LuaTables.num(a, 4, x));
}
return LuaValue.NIL;
});
LuaTables.put(t, "push", a -> {
if (stack != null) { stack.pushPose(); depth++; }
return LuaValue.NIL;
});
LuaTables.put(t, "pop", a -> {
if (stack != null && depth > 0) { stack.popPose(); depth--; }
return LuaValue.NIL;
});
LuaTables.put(t, "sin", a -> LuaValue.valueOf(Math.sin(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "cos", a -> LuaValue.valueOf(Math.cos(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "abs", a -> LuaValue.valueOf(Math.abs(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "round", a -> LuaValue.valueOf((double) Math.round(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "floor", a -> LuaValue.valueOf(Math.floor(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "ceil", a -> LuaValue.valueOf(Math.ceil(LuaTables.num(a, 2, 0))));
LuaTables.put(t, "pow", a -> LuaValue.valueOf(Math.pow(LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 1))));
LuaTables.put(t, "clamp", a -> LuaValue.valueOf(
Math.max(LuaTables.num(a, 3, 0), Math.min(LuaTables.num(a, 4, 1), LuaTables.num(a, 2, 0)))));
LuaTables.put(t, "lerp", a -> {
double from = LuaTables.num(a, 2, 0);
return LuaValue.valueOf(from + (LuaTables.num(a, 3, 0) - from) * LuaTables.num(a, 4, 0));
});
return t;
}
private LuaValue move(double x, double y, double z) {
if (stack != null) stack.translate((float) x, (float) y, (float) z);
return LuaValue.NIL;
}
private LuaValue rot(Axis axis, org.luaj.vm2.Varargs a) {
rotate(axis, LuaTables.num(a, 2, 0), LuaTables.num(a, 3, 0), LuaTables.num(a, 4, 0), LuaTables.num(a, 5, 0));
return LuaValue.NIL;
}
}

View file

@ -0,0 +1,37 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Препроцессор скриптов: строка {@code global.x = v;} становится локальной переменной,
* которая берётся из {@code registry} (живёт между кадрами), а в конец скрипта дописывается
* сохранение значений обратно. Так скрипту не нужны собственные глобальные таблицы.
*/
public final class LuaPreprocessor {
private static final Pattern GLOBAL_DECL =
Pattern.compile("(?m)^[ \\t]*global\\.([A-Za-z_][A-Za-z0-9_]*)[ \\t]*=[ \\t]*(.+?)[ \\t]*;?[ \\t]*$");
private LuaPreprocessor() {
}
public static String process(String source) {
List<String> names = new ArrayList<>();
Matcher m = GLOBAL_DECL.matcher(source);
StringBuilder out = new StringBuilder();
while (m.find()) {
String name = m.group(1);
names.add(name);
String line = "local " + name + " = registry:getOrDefault('" + name + "', " + m.group(2) + ")";
m.appendReplacement(out, Matcher.quoteReplacement(line));
}
m.appendTail(out);
for (String name : names) {
out.append("\nregistry:put('").append(name).append("', ").append(name).append(')');
}
return out.toString();
}
}

View file

@ -0,0 +1,65 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime;
import org.luaj.vm2.Globals;
import org.luaj.vm2.LoadState;
import org.luaj.vm2.LuaError;
import org.luaj.vm2.LuaValue;
import org.luaj.vm2.Varargs;
import org.luaj.vm2.compiler.LuaC;
import org.luaj.vm2.lib.BaseLib;
import org.luaj.vm2.lib.Bit32Lib;
import org.luaj.vm2.lib.DebugLib;
import org.luaj.vm2.lib.PackageLib;
import org.luaj.vm2.lib.StringLib;
import org.luaj.vm2.lib.TableLib;
import org.luaj.vm2.lib.VarArgFunction;
import org.luaj.vm2.lib.jse.JseMathLib;
/**
* Песочница LuaJ: только base/bit32/table/string/math. Нет io, os, luajava, package, файловых
* загрузчиков и debug; Java-объекты в Lua не отдаются вообще (API собирается из таблиц с функциями).
* Бесконечные циклы обрезаются счётчиком инструкций через хук, который ставится один раз.
*/
public final class LuaSandbox {
/** Шаг хука в инструкциях и допустимое число шагов на один запуск (≈ 400k инструкций). */
private static final int HOOK_STEP = 1000;
private static final int HOOK_LIMIT = 400;
private static final String[] REMOVED = {"package", "require", "dofile", "loadfile", "load", "loadstring", "debug", "collectgarbage"};
private final Globals globals;
private int budget;
public LuaSandbox() {
Globals g = new Globals();
g.load(new BaseLib());
g.load(new PackageLib());
g.load(new Bit32Lib());
g.load(new TableLib());
g.load(new StringLib());
g.load(new JseMathLib());
g.load(new DebugLib());
LoadState.install(g);
LuaC.install(g);
LuaValue hook = new VarArgFunction() {
@Override
public Varargs invoke(Varargs args) {
if (++budget > HOOK_LIMIT) throw new LuaError("script is too slow (instruction limit)");
return NONE;
}
};
g.get("debug").get("sethook").invoke(LuaValue.varargsOf(
new LuaValue[]{hook, LuaValue.valueOf(""), LuaValue.valueOf(HOOK_STEP)}));
for (String name : REMOVED) g.set(name, LuaValue.NIL);
this.globals = g;
}
public Globals globals() {
return globals;
}
/** Сбрасывает бюджет инструкций перед очередным запуском скрипта. */
public void beginRun() {
budget = 0;
}
}

View file

@ -0,0 +1,81 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime;
import org.luaj.vm2.LuaError;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
import org.luaj.vm2.lib.VarArgFunction;
import org.luaj.vm2.Varargs;
import java.util.HashMap;
import java.util.Map;
/**
* Один загруженный скрипт: скомпилированный чанк, свой {@code registry} и флаг «упал».
* После ошибки скрипт не запускается, пока его не перезагрузят (правка файла или реинициализация).
*/
public final class ScriptSlot {
private final String name;
private final LuaSandbox sandbox;
private final Map<String, LuaValue> store = new HashMap<>();
private final LuaTable registry = buildRegistry();
private LuaValue chunk;
private String error;
public ScriptSlot(String name, String source, LuaSandbox sandbox) {
this.name = name;
this.sandbox = sandbox;
try {
this.chunk = sandbox.globals().load(LuaPreprocessor.process(source), name);
} catch (LuaError e) {
this.error = e.getMessage();
}
}
public String name() {
return name;
}
public String error() {
return error;
}
public boolean canRun() {
return chunk != null && error == null;
}
/** Таблица {@code registry} для глобалов скрипта: getOrDefault/put. */
public LuaTable registryTable() {
return registry;
}
private LuaTable buildRegistry() {
LuaTable t = new LuaTable();
t.set("getOrDefault", new VarArgFunction() {
@Override
public Varargs invoke(Varargs a) {
LuaValue v = store.get(a.arg(2).tojstring());
return v != null ? v : a.arg(3);
}
});
t.set("put", new VarArgFunction() {
@Override
public Varargs invoke(Varargs a) {
store.put(a.arg(2).tojstring(), a.arg(3));
return NONE;
}
});
return t;
}
/** Запускает чанк; ошибка Lua блокирует скрипт и сохраняется в {@link #error()}. */
public void run() {
if (!canRun()) return;
sandbox.beginRun();
try {
chunk.call();
} catch (LuaError e) {
error = e.getMessage();
}
}
}

View file

@ -1,60 +1,69 @@
package dev.loki.lovisual.features.module.modules.visuals.player;
import com.mojang.blaze3d.pipeline.RenderPipeline;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.config.common.CommonSettingSchemas;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.EnumValue;
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.player.jumpcircles.CircleAnimation;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.CircleColorMode;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.CircleRenderer;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.JumpCircle;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.Shape;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.math.ColorMath;
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.time.Timer;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Player;
import java.util.*;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.IntUnaryOperator;
//todo Description
/** Draws an animated shape on the ground where a player left it by jumping. */
@ModuleInfo(
id = "jumpcircles",
displayName = "JumpCircles",
category = ModuleCategory.VISUALS
)
public class JumpCircles extends Module {
private static final double SURFACE_LIFT = 0.02;
private final EnumValue<CircleMode> mode =
enumSetting("jumpCirclesMode", "mode", CircleMode.DEFAULT);
private final BooleanValue easeOut = bool("jumpCirclesEaseOut", "ease_out", true);
private final NumberValue<Float> rotateSpeed = num("jumpCirclesRotateSpeed", "rotate_speed", 2f, 0.5f, 5f);
private final NumberValue<Float> circleScale = common(
num("jumpCirclesCircleScale", "circle_scale", 1f, 0.5f, 5f),
enumSetting("jumpCirclesMode", "mode", CircleMode.CIRCLE);
private final NumberValue<Float> radius = common(
num("jumpCirclesCircleScale", "circle_scale", 1.5f, 0.5f, 5f),
CommonSettingSchemas.RENDER_SCALE.commonI18nKey()
);
private final NumberValue<Float> pulseMaxSize = visibleWhen(num(
"jumpCirclesPulseMaxSize", "pulse_max_size", 2.5f, 1.0f, 3.0f
), this::isPulseMode);
private final NumberValue<Integer> pulseLifetime = visibleWhen(num(
"jumpCirclesPulseLifetime", "pulse_lifetime", 1000, 500, 5000
), this::isPulseMode);
private final NumberValue<Float> speed = num("jumpCirclesSpeed", "speed", 1f, 0.5f, 3f);
private final NumberValue<Float> fadeSpeed = num("jumpCirclesFadeSpeed", "fade_speed", 1f, 0.5f, 3f);
private final NumberValue<Float> rotateSpeed = num("jumpCirclesRotateSpeed", "rotate_speed", 1f, 0f, 5f);
private final NumberValue<Float> thickness = visibleWhen(
num("jumpCirclesThickness", "thickness", 0.25f, 0.08f, 0.5f), this::usesThickness);
private final NumberValue<Integer> segments = visibleWhen(
num("jumpCirclesSegments", "segments", 32, 12, 96), this::usesSegments);
private final NumberValue<Integer> maxCircles = num("jumpCirclesMax", "max_circles", 12, 1, 32);
private final BooleanValue onlySelf = bool("jumpCirclesOnlySelf", "only_self", false);
private final BooleanValue depthTest = common(
bool("jumpCirclesDepthTest", "depth_test", true),
CommonSettingSchemas.RENDER_DEPTH_TEST.commonI18nKey()
);
private final EnumValue<ColorMode> colorMode =
enumCommon("jumpCirclesColorMode", "color_mode",
CommonSettingSchemas.RENDER_COLOR_MODE.commonI18nKey(), ColorMode.STATIC);
private final EnumValue<CircleColorMode> colorMode =
enumCommon("jumpCirclesCircleColorMode", "color_mode",
CommonSettingSchemas.RENDER_COLOR_MODE.commonI18nKey(), CircleColorMode.STATIC);
private final NumberValue<Integer> colorSpeed = common(
num("jumpCirclesColorSpeed", "color_speed", 18, 2, 54),
CommonSettingSchemas.RENDER_COLOR_SPEED.commonI18nKey()
@ -66,9 +75,12 @@ public class JumpCircles extends Module {
private final RGBAColorValue colorValue2 = visibleWhen(common(
color("jumpCirclesColor2", "color2", "#FFFF5555"),
CommonSettingSchemas.RENDER_SECONDARY_COLOR.commonI18nKey()
), this::usesSecondaryColor);
private final List<Circle> circles = new ArrayList<>();
private final Map<UUID, Boolean> groundState = new HashMap<>();
), () -> AnimatedRenderColors.usesSecondary(colorMode.get().animated()));
private final Deque<JumpCircle> circles = new ArrayDeque<>();
private final Map<UUID, Double> lastGroundY = new HashMap<>();
private final Set<UUID> seen = new HashSet<>();
private final CircleRenderer circleRenderer = new CircleRenderer();
@Override
public WorldPhase getWorldPhase() {
@ -80,33 +92,28 @@ public class JumpCircles extends Module {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null) return;
boolean selfOnly = onlySelf.get();
Set<UUID> active = new HashSet<>();
seen.clear();
for (Player pl : mc.level.players()) {
if (selfOnly && pl != mc.player) continue;
if (onlySelf.get() && pl != mc.player) continue;
UUID id = pl.getUUID();
active.add(id);
boolean prevGround = groundState.getOrDefault(id, false);
boolean nowGround = pl.onGround();
if (prevGround && !nowGround) {
circles.add(new Circle(
new Vec3(pl.getX(), Math.floor(pl.getY()) + 0.001f, pl.getZ()),
new Timer()
));
seen.add(id);
if (pl.onGround()) {
lastGroundY.put(id, pl.getY());
} else {
Double groundY = lastGroundY.remove(id);
if (groundY != null) spawn(pl.getX(), groundY, pl.getZ());
}
groundState.put(id, nowGround);
}
groundState.keySet().removeIf(id -> !active.contains(id));
circles.removeIf(c -> c.timer.passedMs(lifetimeMs()));
lastGroundY.keySet().retainAll(seen);
long now = GameClock.millis();
float lifetime = CircleAnimation.lifetimeMs(speed.get());
circles.removeIf(c -> now - c.spawnMs() >= lifetime);
}
@Override
public void onDisable() {
circles.clear();
groundState.clear();
lastGroundY.clear();
}
@Override
@ -114,185 +121,78 @@ public class JumpCircles extends Module {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null || circles.isEmpty()) return;
boolean useDepth = depthTest.get();
RenderPipeline pipeline = useDepth
? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE
: LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE;
Renderer3D.DepthMode depthMode = useDepth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
MeshBuilder mesh = renderer.batchTextured(pipeline, resolveTexture(), depthMode);
CircleMode circleMode = mode.get();
Shape shape = circleMode.shape;
MeshBuilder mesh = circleRenderer.openMesh(renderer, shape, circleMode.texture, depthTest.get());
if (mesh == null) return;
float nowMs = GameClock.millis() + tickDelta * GameClock.TICK_MS;
float lifetime = CircleAnimation.lifetimeMs(speed.get());
IntUnaryOperator palette = this::colorAt;
for (JumpCircle c : circles) {
float age = Math.max(0f, nowMs - c.spawnMs());
float progress = CircleAnimation.progress(age, lifetime);
float alpha = CircleAnimation.alpha(progress, fadeSpeed.get());
if (progress >= 1f || alpha <= 0f) continue;
for (int i = circles.size() - 1; i >= 0; i--) {
Circle c = circles.get(i);
if (isPulseMode()) {
renderPulseCircle(mesh, c);
CircleRenderer.Pose pose = new CircleRenderer.Pose(c.x(), c.y(), c.z(),
CircleAnimation.radius(progress, radius.get()),
CircleAnimation.angle(age / 1000f, rotateSpeed.get(), progress),
alpha, CircleAnimation.wavePhase(age / 1000f));
if (shape == null) {
circleRenderer.texturedQuad(mesh, pose, palette);
} else {
renderLegacyCircle(mesh, c);
circleRenderer.shape(mesh, pose, shape, segments.get(), thickness.get(), palette);
}
}
}
private void renderLegacyCircle(MeshBuilder mesh, Circle c) {
float colorAnim = c.timer.getPassedTimeMs() / 6000f;
float t = (c.timer.getPassedTimeMs() * (easeOut.get() ? 2f : 1f)) / 5000f;
float sizeAnim = circleScale.get() - (float) Math.pow(1f - t, 4f);
if (sizeAnim <= 0f) return;
float angleDeg = sizeAnim * rotateSpeed.get() * 1000f;
emitCircleQuad(mesh, c.pos, sizeAnim, angleDeg, 1f - colorAnim);
private void spawn(double x, double groundY, double z) {
circles.addLast(new JumpCircle(x, groundY + SURFACE_LIFT, z, GameClock.millis()));
while (circles.size() > maxCircles.get()) {
circles.removeFirst();
}
}
private void renderPulseCircle(MeshBuilder mesh, Circle c) {
float progress = Math.min(c.timer.getPassedTimeMs() / (float) pulseLifetime.get(), 1f);
if (progress >= 1f) return;
float scale = bounceOut(progress) * pulseMaxSize.get() * circleScale.get();
if (scale <= 0f) return;
float alpha = pulseAlpha(progress);
emitCircleQuad(mesh, c.pos, scale * 0.5f, 0f, alpha);
private boolean usesThickness() {
return mode.get().shape != null && mode.get().shape.usesThickness();
}
private void emitCircleQuad(MeshBuilder mesh, Vec3 center, float half, float angleDeg, float alpha) {
double radians = Math.toRadians(angleDeg);
float cos = (float) Math.cos(radians);
float sin = (float) Math.sin(radians);
Vec3 right = new Vec3(cos, 0.0, -sin);
Vec3 forward = new Vec3(sin, 0.0, cos);
int c1 = ColorMath.scaleAlpha(getColorArgb(270), alpha);
int c2 = ColorMath.scaleAlpha(getColorArgb(0), alpha);
int c3 = ColorMath.scaleAlpha(getColorArgb(180), alpha);
int c4 = ColorMath.scaleAlpha(getColorArgb(90), alpha);
Vec3 p1 = center.add(right.scale(-half)).add(forward.scale(half));
Vec3 p2 = center.add(right.scale(half)).add(forward.scale(half));
Vec3 p3 = center.add(right.scale(half)).add(forward.scale(-half));
Vec3 p4 = center.add(right.scale(-half)).add(forward.scale(-half));
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(new RenderColor(c1)).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(new RenderColor(c2)).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(new RenderColor(c3)).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(new RenderColor(c4)).next();
mesh.quad(i1, i2, i3, i4);
private boolean usesSegments() {
return mode.get().shape != null && mode.get().shape.usesSegments();
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(animatedColorMode());
}
private boolean isPulseMode() {
return mode.get() == CircleMode.PULSE;
}
private long lifetimeMs() {
return isPulseMode() ? pulseLifetime.get() : (easeOut.get() ? 5000L : 6000L);
}
private int getColorArgb(int count) {
private int colorAt(int phase) {
return AnimatedRenderColors.resolve(
animatedColorMode(),
colorSpeed.get(),
count,
colorValue.getArgb(),
colorValue2.getArgb()
);
colorMode.get().animated(), colorSpeed.get(), phase, colorValue.getArgb(), colorValue2.getArgb());
}
private AnimatedRenderColors.Mode animatedColorMode() {
return switch (colorMode.get()) {
case RAINBOW -> AnimatedRenderColors.Mode.RAINBOW;
case LIGHT_RAINBOW -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case SKY -> AnimatedRenderColors.Mode.SKY;
case FADE -> AnimatedRenderColors.Mode.FADE;
case DOUBLE_COLOR -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case ANALOGOUS -> AnimatedRenderColors.Mode.ANALOGOUS;
case THEME -> AnimatedRenderColors.Mode.THEME;
case STATIC -> AnimatedRenderColors.Mode.STATIC;
};
}
private enum CircleMode implements EnumValue.AliasProvider {
CIRCLE(TextureStorage.DEFAULT_CIRCLE, null),
RING(TextureStorage.JUMP_CIRCLE, null),
BUBBLE(TextureStorage.BUBBLE, null),
STAR(null, Shape.STAR),
DIAMOND(null, Shape.DIAMOND),
SPIRAL(null, Shape.SPIRAL),
HEXAGON(null, Shape.HEXAGON),
WAVE(null, Shape.WAVE),
CROSS(null, Shape.CROSS),
PETALS(null, Shape.PETALS);
private Identifier resolveTexture() {
return switch (mode.get()) {
case BUBBLE -> TextureStorage.BUBBLE;
case PULSE -> TextureStorage.JUMP_CIRCLE;
case DEFAULT -> TextureStorage.DEFAULT_CIRCLE;
};
}
private final Identifier texture;
private final Shape shape;
private float pulseAlpha(float progress) {
final float fadeInDuration = 0.15f;
final float glowStart = 0.65f;
final float fadeOutStart = 0.85f;
float alpha;
if (progress < fadeInDuration) {
alpha = progress / fadeInDuration;
} else if (progress >= fadeOutStart) {
float fadeOutProgress = (progress - fadeOutStart) / (1f - fadeOutStart);
alpha = 1f - fadeOutProgress;
if (progress > glowStart) {
float glowProgress = (progress - glowStart) / (fadeOutStart - glowStart);
float glowPulse = (float) (Math.sin(glowProgress * Math.PI * 3) * 0.3 + 0.3);
alpha += glowPulse * (1f - fadeOutProgress);
}
} else if (progress > glowStart) {
float glowProgress = (progress - glowStart) / (fadeOutStart - glowStart);
float glowPulse = (float) (Math.sin(glowProgress * Math.PI * 3) * 0.3 + 0.3);
alpha = 1f + glowPulse;
} else {
alpha = 1f;
CircleMode(Identifier texture, Shape shape) {
this.texture = texture;
this.shape = shape;
}
return Math.max(0f, Math.min(1f, alpha));
}
private float bounceOut(float value) {
float n1 = 7.5625f;
float d1 = 2.75f;
if (value < 1.0f / d1) {
return n1 * value * value;
} else if (value < 2.0f / d1) {
value -= 1.5f / d1;
return n1 * value * value + 0.75f;
} else if (value < 2.5f / d1) {
value -= 2.25f / d1;
return n1 * value * value + 0.9375f;
} else {
value -= 2.625f / d1;
return n1 * value * value + 0.984375f;
}
}
private enum CircleMode {
DEFAULT,
BUBBLE,
PULSE
}
private enum ColorMode implements EnumValue.AliasProvider {
STATIC,
RAINBOW,
LIGHT_RAINBOW,
SKY,
FADE,
DOUBLE_COLOR,
ANALOGOUS,
THEME;
@Override
public List<String> aliases() {
return switch (this) {
case LIGHT_RAINBOW -> List.of("LightRainbow");
case DOUBLE_COLOR -> List.of("DoubleColor");
case THEME -> List.of("Theme");
case CIRCLE -> List.of("Default");
case RING -> List.of("Pulse");
default -> List.of();
};
}
}
private record Circle(Vec3 pos, Timer timer) {
}
}

View file

@ -0,0 +1,56 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles;
/** Pure timing and easing maths for a single jump circle; no Minecraft dependencies. */
public final class CircleAnimation {
public static final float BASE_LIFETIME_MS = 2400f;
private static final float TWO_PI = (float) (Math.PI * 2.0);
private static final float FADE_IN = 0.08f;
private static final float SCALE_PULSE = 0.04f;
private static final float ALPHA_PULSE = 0.15f;
private static final float SWAY = 0.15f;
private static final float SPIN_PER_SECOND = 0.8f;
private static final float WAVE_RADIANS_PER_SECOND = 3f;
private CircleAnimation() {
}
public static float lifetimeMs(float speed) {
return BASE_LIFETIME_MS / Math.max(0.1f, speed);
}
public static float progress(float ageMs, float lifetimeMs) {
return clamp01(ageMs / lifetimeMs);
}
public static float easeOutCubic(float p) {
float inv = 1f - clamp01(p);
return 1f - inv * inv * inv;
}
/** Eased growth to {@code maxRadius} with a slight breathing; never exceeds {@code maxRadius}. */
public static float radius(float progress, float maxRadius) {
float breathe = 0.5f + 0.5f * (float) Math.sin(progress * TWO_PI * 4f);
return maxRadius * easeOutCubic(progress) * (1f - SCALE_PULSE + SCALE_PULSE * breathe);
}
/** Short fade-in, then a fade-out whose steepness grows with {@code fadeSpeed}; result is in [0,1]. */
public static float alpha(float progress, float fadeSpeed) {
float in = clamp01(progress / FADE_IN);
float out = clamp01((1f - progress) * Math.max(0f, fadeSpeed));
float flicker = 1f - ALPHA_PULSE + ALPHA_PULSE * (0.5f + 0.5f * (float) Math.sin(progress * TWO_PI * 5f));
return clamp01(in * out * flicker);
}
/** Steady spin over time plus a small sway driven by progress, in radians. */
public static float angle(float ageSeconds, float rotateSpeed, float progress) {
return ageSeconds * rotateSpeed * SPIN_PER_SECOND + SWAY * (float) Math.sin(progress * TWO_PI * 2f);
}
public static float wavePhase(float ageSeconds) {
return ageSeconds * WAVE_RADIANS_PER_SECOND;
}
private static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
}

View file

@ -0,0 +1,38 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import java.util.List;
/** Colour source options for JumpCircles, mapped onto the shared animated render colours. */
public enum CircleColorMode implements EnumValue.AliasProvider {
STATIC(AnimatedRenderColors.Mode.STATIC),
RAINBOW(AnimatedRenderColors.Mode.RAINBOW),
LIGHT_RAINBOW(AnimatedRenderColors.Mode.LIGHT_RAINBOW),
SKY(AnimatedRenderColors.Mode.SKY),
FADE(AnimatedRenderColors.Mode.FADE),
DOUBLE_COLOR(AnimatedRenderColors.Mode.DOUBLE_COLOR),
ANALOGOUS(AnimatedRenderColors.Mode.ANALOGOUS),
THEME(AnimatedRenderColors.Mode.THEME);
private final AnimatedRenderColors.Mode animated;
CircleColorMode(AnimatedRenderColors.Mode animated) {
this.animated = animated;
}
public AnimatedRenderColors.Mode animated() {
return animated;
}
@Override
public List<String> aliases() {
return switch (this) {
case LIGHT_RAINBOW -> List.of("LightRainbow");
case DOUBLE_COLOR -> List.of("DoubleColor");
case THEME -> List.of("Theme");
default -> List.of();
};
}
}

View file

@ -0,0 +1,88 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.Shape;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.ShapeGeometry;
import dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape.ShapeMesh;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
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 com.mojang.blaze3d.pipeline.RenderPipeline;
import net.minecraft.resources.Identifier;
import java.util.function.IntUnaryOperator;
/** Writes jump circles into world meshes: coloured shape triangles or one textured quad. */
public final class CircleRenderer {
private static final float OUTLINE_BRIGHTNESS = 0.35f;
private final ShapeMesh shapeMesh = new ShapeMesh();
/** Where and how a circle is drawn this frame; angle and wavePhase are in radians. */
public record Pose(double x, double y, double z, float radius, float angle, float alpha, float wavePhase) {
}
/** Additive batch for shapes (coloured) or for the texture when {@code shape} is null; depth mask is never written. */
public MeshBuilder openMesh(Renderer3D renderer, Shape shape, Identifier texture, boolean depthTest) {
Renderer3D.DepthMode depthMode = depthTest ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
if (shape != null) {
RenderPipeline pipeline = depthTest
? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE;
return renderer.batch(pipeline, depthMode);
}
RenderPipeline pipeline = depthTest
? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE
: LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE;
return renderer.batchTextured(pipeline, texture, depthMode);
}
/** {@code palette} maps a 0..360 gradient phase to ARGB. */
public void shape(MeshBuilder mesh, Pose pose, Shape shape, int segments, float thickness,
IntUnaryOperator palette) {
ShapeGeometry.build(shape, shapeMesh, segments, thickness, pose.wavePhase());
int vertices = shapeMesh.vertexCount();
int indices = shapeMesh.indexCount();
mesh.ensureCapacity(vertices, indices);
float cos = (float) Math.cos(pose.angle());
float sin = (float) Math.sin(pose.angle());
int base = -1;
for (int i = 0; i < vertices; i++) {
float lx = shapeMesh.x(i) * pose.radius();
float lz = shapeMesh.z(i) * pose.radius();
int main = palette.applyAsInt(Math.round(shapeMesh.gradient(i) * 360f));
int argb = AnimatedRenderColors.mixArgb(main,
AnimatedRenderColors.brighten(main, OUTLINE_BRIGHTNESS), shapeMesh.shade(i));
int v = put(mesh, pose.x() + lx * cos - lz * sin, pose.y(), pose.z() + lx * sin + lz * cos,
AnimatedRenderColors.scaleAlpha(argb, pose.alpha()), false, 0, 0);
if (base < 0) base = v;
}
for (int i = 0; i < indices; i += 3) {
mesh.triangle(base + shapeMesh.index(i), base + shapeMesh.index(i + 1), base + shapeMesh.index(i + 2));
}
}
public void texturedQuad(MeshBuilder mesh, Pose pose, IntUnaryOperator palette) {
float r = pose.radius();
float cos = (float) Math.cos(pose.angle()) * r;
float sin = (float) Math.sin(pose.angle()) * r;
mesh.ensureQuadCapacity();
int a = corner(mesh, pose, -cos - sin, cos - sin, 0, 1, palette.applyAsInt(270));
int b = corner(mesh, pose, cos - sin, cos + sin, 1, 1, palette.applyAsInt(0));
int c = corner(mesh, pose, cos + sin, sin - cos, 1, 0, palette.applyAsInt(180));
int d = corner(mesh, pose, -cos + sin, -sin - cos, 0, 0, palette.applyAsInt(90));
mesh.quad(a, b, c, d);
}
private static int corner(MeshBuilder mesh, Pose pose, double dx, double dz, double u, double v, int argb) {
return put(mesh, pose.x() + dx, pose.y(), pose.z() + dz,
AnimatedRenderColors.scaleAlpha(argb, pose.alpha()), true, u, v);
}
private static int put(MeshBuilder mesh, double x, double y, double z, int argb,
boolean textured, double u, double v) {
mesh.vec3(x, y, z);
if (textured) mesh.vec2(u, v);
mesh.color((argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, argb >>> 24);
return mesh.next();
}
}

View file

@ -0,0 +1,5 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles;
/** A circle born at the take-off point; {@code spawnMs} is GameClock time. */
public record JumpCircle(double x, double y, double z, long spawnMs) {
}

View file

@ -0,0 +1,28 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape;
/** Ground-plane shapes JumpCircles can draw; flags tell which tuning parameters a shape honours. */
public enum Shape {
STAR(false, false),
DIAMOND(false, false),
SPIRAL(true, true),
HEXAGON(true, false),
WAVE(true, true),
CROSS(false, false),
PETALS(false, true);
private final boolean usesThickness;
private final boolean usesSegments;
Shape(boolean usesThickness, boolean usesSegments) {
this.usesThickness = usesThickness;
this.usesSegments = usesSegments;
}
public boolean usesThickness() {
return usesThickness;
}
public boolean usesSegments() {
return usesSegments;
}
}

View file

@ -0,0 +1,176 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape;
/** Pure geometry for the JumpCircles shapes; every vertex stays inside the unit circle. */
public final class ShapeGeometry {
public static final float EDGE_SHADE = 0.55f;
private static final float TWO_PI = (float) (Math.PI * 2.0);
private static final int STAR_POINTS = 5;
private static final float STAR_INNER = 0.45f;
private static final float DIAMOND_WIDTH = 0.7f;
private static final int SPIRAL_ARMS = 3;
private static final int HEX_SIDES = 6;
private static final float WAVE_AMPLITUDE = 0.08f;
private static final float WAVE_COUNT = 6.0f;
private static final float CROSS_CORE = 0.12f;
private static final int PETAL_COUNT = 6;
private static final float PETAL_CENTER = 0.55f;
private static final float PETAL_LENGTH = 0.45f;
private static final float PETAL_WIDTH = 0.2f;
private ShapeGeometry() {
}
/**
* Rebuilds {@code out} for the given shape.
*
* @param segments detail per arm, petal or ring (used by SPIRAL, WAVE, PETALS)
* @param thickness ring band width in unit radius (used by SPIRAL, HEXAGON, WAVE)
* @param phase wave travel phase in radians (used by WAVE)
*/
public static void build(Shape shape, ShapeMesh out, int segments, float thickness, float phase) {
out.clear();
int seg = Math.max(3, segments);
float band = Math.max(0.02f, Math.min(0.5f, thickness));
switch (shape) {
case STAR -> star(out);
case DIAMOND -> diamond(out);
case SPIRAL -> spiral(out, seg, band);
case HEXAGON -> hexagon(out, band);
case WAVE -> wave(out, Math.max(8, seg), band, phase);
case CROSS -> cross(out);
case PETALS -> petals(out, seg);
}
}
private static void star(ShapeMesh m) {
int center = m.vertex(0, 0, 0.5f, 0);
int n = STAR_POINTS * 2;
int prev = -1;
for (int i = 0; i <= n; i++) {
int k = i % n;
boolean tip = (k & 1) == 0;
float a = TWO_PI * k / n - (float) Math.PI / 2f;
float r = tip ? 1f : STAR_INNER;
int v = m.vertex((float) Math.cos(a) * r, (float) Math.sin(a) * r,
seamless((float) k / n), tip ? EDGE_SHADE : 0.2f);
if (prev >= 0) m.triangle(center, prev, v);
prev = v;
}
}
private static void diamond(ShapeMesh m) {
int center = m.vertex(0, 0, 0.5f, 0);
int north = m.vertex(0, -1, 0f, EDGE_SHADE);
int east = m.vertex(DIAMOND_WIDTH, 0, 0.5f, EDGE_SHADE);
int south = m.vertex(0, 1, 1f, EDGE_SHADE);
int west = m.vertex(-DIAMOND_WIDTH, 0, 0.5f, EDGE_SHADE);
m.triangle(center, north, east);
m.triangle(center, east, south);
m.triangle(center, south, west);
m.triangle(center, west, north);
}
private static void spiral(ShapeMesh m, int segments, float band) {
for (int arm = 0; arm < SPIRAL_ARMS; arm++) {
float start = TWO_PI * arm / SPIRAL_ARMS;
int prevIn = -1;
int prevOut = -1;
for (int k = 0; k <= segments; k++) {
float u = (float) k / segments;
float a = start + u * TWO_PI * 0.3f;
float center = (1f - band * 0.5f) * (0.5f + 0.5f * u);
float half = band * 0.5f * (float) Math.sin(Math.PI * u);
float grad = seamless((arm + u) / SPIRAL_ARMS);
int in = polar(m, a, center - half, grad, 0f);
int out = polar(m, a, center + half, grad, EDGE_SHADE);
strip(m, prevIn, prevOut, in, out);
prevIn = in;
prevOut = out;
}
}
}
private static void hexagon(ShapeMesh m, float band) {
for (int i = 0; i < HEX_SIDES; i++) {
float a0 = TWO_PI * i / HEX_SIDES;
float a1 = TWO_PI * (i + 1) / HEX_SIDES;
float shade = (i & 1) == 0 ? 0f : EDGE_SHADE;
float grad = seamless((float) i / HEX_SIDES);
int in0 = polar(m, a0, 1f - band, grad, shade);
int out0 = polar(m, a0, 1f, grad, shade);
int in1 = polar(m, a1, 1f - band, grad, shade);
int out1 = polar(m, a1, 1f, grad, shade);
strip(m, in0, out0, in1, out1);
}
}
private static void wave(ShapeMesh m, int segments, float band, float phase) {
int prevIn = -1;
int prevOut = -1;
int firstIn = -1;
int firstOut = -1;
for (int k = 0; k < segments; k++) {
float a = TWO_PI * k / segments;
float outer = 1f - WAVE_AMPLITUDE + WAVE_AMPLITUDE * (float) Math.sin(WAVE_COUNT * a + phase);
float grad = seamless((float) k / segments);
int in = polar(m, a, Math.max(0f, outer - band), grad, 0f);
int out = polar(m, a, outer, grad, EDGE_SHADE);
if (firstIn < 0) {
firstIn = in;
firstOut = out;
}
strip(m, prevIn, prevOut, in, out);
prevIn = in;
prevOut = out;
}
strip(m, prevIn, prevOut, firstIn, firstOut);
}
private static void cross(ShapeMesh m) {
int c0 = m.vertex(CROSS_CORE, -CROSS_CORE, 0.5f, 0f);
int c1 = m.vertex(CROSS_CORE, CROSS_CORE, 0.5f, 0f);
int c2 = m.vertex(-CROSS_CORE, CROSS_CORE, 0.5f, 0f);
int c3 = m.vertex(-CROSS_CORE, -CROSS_CORE, 0.5f, 0f);
m.triangle(c0, c1, c2);
m.triangle(c0, c2, c3);
m.triangle(c0, c1, m.vertex(1f, 0f, 0f, EDGE_SHADE));
m.triangle(c1, c2, m.vertex(0f, 1f, 0.5f, EDGE_SHADE));
m.triangle(c2, c3, m.vertex(-1f, 0f, 1f, EDGE_SHADE));
m.triangle(c3, c0, m.vertex(0f, -1f, 0.5f, EDGE_SHADE));
}
private static void petals(ShapeMesh m, int segments) {
for (int p = 0; p < PETAL_COUNT; p++) {
float a = TWO_PI * p / PETAL_COUNT;
float cos = (float) Math.cos(a);
float sin = (float) Math.sin(a);
float grad = seamless((float) p / PETAL_COUNT);
int center = m.vertex(cos * PETAL_CENTER, sin * PETAL_CENTER, grad, 0f);
int prev = -1;
for (int k = 0; k <= segments; k++) {
float u = TWO_PI * k / segments;
float along = PETAL_CENTER + PETAL_LENGTH * (float) Math.cos(u);
float side = PETAL_WIDTH * (float) Math.sin(u);
int v = m.vertex(cos * along - sin * side, sin * along + cos * side, grad, EDGE_SHADE);
if (prev >= 0) m.triangle(center, prev, v);
prev = v;
}
}
}
private static int polar(ShapeMesh m, float angle, float radius, float gradient, float shade) {
return m.vertex((float) Math.cos(angle) * radius, (float) Math.sin(angle) * radius, gradient, shade);
}
/** Joins two consecutive inner/outer vertex pairs of a band; the first pair has index -1 and is skipped. */
private static void strip(ShapeMesh m, int in0, int out0, int in1, int out1) {
if (in0 < 0) return;
m.triangle(in0, out0, out1);
m.triangle(in0, out1, in1);
}
/** Maps [0,1] to a there-and-back ramp so a gradient wraps around a closed ring without a seam. */
private static float seamless(float f) {
return 1f - Math.abs(2f * f - 1f);
}
}

View file

@ -0,0 +1,74 @@
package dev.loki.lovisual.features.module.modules.visuals.player.jumpcircles.shape;
import java.util.Arrays;
/**
* Reusable triangle list in the unit ground plane. Each vertex carries a gradient position
* {@code t} in [0,1] and a {@code shade} in [0,1] (0 = main colour, 1 = darkened outline colour).
*/
public final class ShapeMesh {
private float[] x = new float[64];
private float[] z = new float[64];
private float[] t = new float[64];
private float[] shade = new float[64];
private int[] indices = new int[192];
private int vertexCount;
private int indexCount;
public void clear() {
vertexCount = 0;
indexCount = 0;
}
public int vertex(float px, float pz, float gradient, float vertexShade) {
if (vertexCount == x.length) {
int size = vertexCount * 2;
x = Arrays.copyOf(x, size);
z = Arrays.copyOf(z, size);
t = Arrays.copyOf(t, size);
shade = Arrays.copyOf(shade, size);
}
x[vertexCount] = px;
z[vertexCount] = pz;
t[vertexCount] = gradient;
shade[vertexCount] = vertexShade;
return vertexCount++;
}
public void triangle(int a, int b, int c) {
if (indexCount + 3 > indices.length) {
indices = Arrays.copyOf(indices, indices.length * 2);
}
indices[indexCount++] = a;
indices[indexCount++] = b;
indices[indexCount++] = c;
}
public int vertexCount() {
return vertexCount;
}
public int indexCount() {
return indexCount;
}
public float x(int i) {
return x[i];
}
public float z(int i) {
return z[i];
}
public float gradient(int i) {
return t[i];
}
public float shade(int i) {
return shade[i];
}
public int index(int i) {
return indices[i];
}
}

View file

@ -42,7 +42,7 @@ public final class ServerPolicyClient {
public static void init() {
PayloadTypeRegistry.serverboundPlay().register(Payload.TYPE, Payload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(Payload.TYPE, Payload.CODEC);
ClientPlayNetworking.registerReceiver(Payload.TYPE,
ClientPlayNetworking.registerGlobalReceiver(Payload.TYPE,
(payload, context) -> onPolicy(payload.json()));
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) ->
ClientPlayNetworking.send(new Payload(ServerPolicyCodec.REQUEST_JSON)));

View file

@ -24,7 +24,7 @@ public final class ThemeStore {
final List<ThemeEntry> themes = new ArrayList<>();
final List<ThemeEntry> customThemes = new ArrayList<>();
final Map<String, ThemeEntry> themesById = new LinkedHashMap<>();
String currentId = Themes.THEME_CLASSIC;
String currentId = Themes.THEME_DEFAULT;
private ThemeStore() {
ThemeStoreIO.load(this);

View file

@ -35,10 +35,10 @@ public enum Themes {
public static final String THEME_PRISM = "prism";
public static final String THEME_CINDER = "cinder";
public static final String THEME_FOREST = "forest";
/** Theme used on a fresh install and whenever the saved id is missing. */
public static final String THEME_DEFAULT = THEME_AMBER;
static final long THEME_TRANSITION_MS = 340L;
static final ThemeStore STORE = ThemeStore.get();
/** Display order for the preset picker; also drives {@link #PRESET_ENTRIES}. */
static final List<ThemeEntry> PRESET_LIST = List.of(
ThemePresets1.CLASSIC_ENTRY, ThemePresets1.LEGACY_ENTRY, ThemePresets1.AMBER_ENTRY,
@ -49,6 +49,8 @@ public enum Themes {
ThemePresets3.PRISM_ENTRY, ThemePresets3.CINDER_ENTRY, ThemePresets3.FOREST_ENTRY
);
static final Map<String, ThemeEntry> PRESET_ENTRIES = buildPresetMap(PRESET_LIST);
// Must come after the preset tables: loading the store rebuilds its index from presetEntries().
static final ThemeStore STORE = ThemeStore.get();
private static Map<String, ThemeEntry> buildPresetMap(List<ThemeEntry> list) {
Map<String, ThemeEntry> m = new LinkedHashMap<>();
@ -57,15 +59,15 @@ public enum Themes {
}
// Mutable transition state; read / written by ThemesTransition (same package).
static ThemeEntry currentEntry = ThemePresets1.CLASSIC_ENTRY;
static ThemeEntry transitionFromEntry = ThemePresets1.CLASSIC_ENTRY;
static ThemeEntry animatedEntryCache = ThemePresets1.CLASSIC_ENTRY;
static Theme current = ThemePresets1.CLASSIC;
static String currentId = THEME_CLASSIC;
static Theme transitionFromTheme = ThemePresets1.CLASSIC;
static ThemeEntry currentEntry = ThemePresets1.AMBER_ENTRY;
static ThemeEntry transitionFromEntry = ThemePresets1.AMBER_ENTRY;
static ThemeEntry animatedEntryCache = ThemePresets1.AMBER_ENTRY;
static Theme current = ThemePresets1.AMBER;
static String currentId = THEME_DEFAULT;
static Theme transitionFromTheme = ThemePresets1.AMBER;
static long transitionStartMs = 0L;
static boolean transitionActive = false;
static Theme animatedThemeCache = ThemePresets1.CLASSIC;
static Theme animatedThemeCache = ThemePresets1.AMBER;
static int animatedCacheKey = -1;
static {

View file

@ -33,15 +33,15 @@ final class ThemesTransition {
/** Switch to the theme stored under the given preset / custom id, or fall back to classic. */
static void setTheme(String id) {
if (id == null || id.isBlank()) {
boolean animate = !Themes.THEME_CLASSIC.equals(Themes.currentId) || Themes.transitionActive;
applyThemeFromEntry(ThemePresets1.CLASSIC_ENTRY, animate);
Themes.STORE.setCurrentId(Themes.THEME_CLASSIC);
boolean animate = !Themes.THEME_DEFAULT.equals(Themes.currentId) || Themes.transitionActive;
applyThemeFromEntry(ThemePresets1.AMBER_ENTRY, animate);
Themes.STORE.setCurrentId(Themes.THEME_DEFAULT);
return;
}
String key = id.toLowerCase();
ThemeEntry entry = Themes.STORE.getById(key);
if (entry == null) entry = Themes.PRESET_ENTRIES.get(key);
if (entry == null) entry = ThemePresets1.CLASSIC_ENTRY;
if (entry == null) entry = ThemePresets1.AMBER_ENTRY;
boolean animate = !entry.getId().equals(Themes.currentId) || Themes.transitionActive;
applyThemeFromEntry(entry, animate);
Themes.STORE.setCurrentId(entry.getId());
@ -78,8 +78,8 @@ final class ThemesTransition {
static void applyFromStore() {
String id = Themes.STORE.getCurrentId();
ThemeEntry entry = Themes.STORE.getById(id);
if (entry == null) entry = Themes.PRESET_ENTRIES.get(Themes.THEME_CLASSIC);
if (entry == null) entry = ThemePresets1.CLASSIC_ENTRY;
if (entry == null) entry = Themes.PRESET_ENTRIES.get(Themes.THEME_DEFAULT);
if (entry == null) entry = ThemePresets1.AMBER_ENTRY;
applyThemeFromEntry(entry, false);
}

View file

@ -38,7 +38,7 @@ final class ThemeStoreIO {
retireLegacyFile(store);
rebuildMergedIndex(store);
if (store.getById(store.currentId) == null) {
store.currentId = Themes.THEME_CLASSIC;
store.currentId = Themes.THEME_DEFAULT;
saveState(store);
}
}
@ -47,7 +47,7 @@ final class ThemeStoreIO {
String fromState = readCurrentId(STATE_FILE);
if (fromState != null) return fromState;
String fromLegacy = readCurrentId(LEGACY_FILE);
return fromLegacy != null ? fromLegacy : Themes.THEME_CLASSIC;
return fromLegacy != null ? fromLegacy : Themes.THEME_DEFAULT;
}
private static String readCurrentId(Path file) {

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.hud;
package dev.loki.lovisual.mixinhandlers.hud;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
@ -14,11 +14,11 @@ import dev.loki.lovisual.features.gui.hud.nondraggable.impl.bars.CustomHealthBar
* vanilla {@code extractPlayerHealth} pass is fed neutral values (absorption 0, max health 20) so it
* reserves the correct space without drawing hearts we replace ourselves.
*/
final class GuiHealthLayoutHandler {
public final class GuiHealthLayoutHandler {
private GuiHealthLayoutHandler() {
}
static float ignoreAbsorptionForLayout(Player player) {
public static float ignoreAbsorptionForLayout(Player player) {
if (!Lifecycle.canRunHud()) {
return player.getAbsorptionAmount();
}
@ -32,7 +32,7 @@ final class GuiHealthLayoutHandler {
return player.getAbsorptionAmount();
}
static double clampMaxHealthForLayout(Player player, Holder<Attribute> attribute) {
public static double clampMaxHealthForLayout(Player player, Holder<Attribute> attribute) {
if (!Lifecycle.canRunHud()) {
return player.getAttributes().getValue(attribute);
}

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.hud;
package dev.loki.lovisual.mixinhandlers.hud;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
@ -25,14 +25,14 @@ import dev.loki.lovisual.render.engine.renderer.Renderer2D;
* health bar. All passes are emitted through a deferred {@link Renderer2D} layer so they land after
* vanilla misc overlays, then drop a stratum marker via {@link IGuiGraphics}.
*/
final class GuiHudBarHandler {
public final class GuiHudBarHandler {
private GuiHudBarHandler() {
}
static final Renderer2D.Deferred2DLayer VANILLA_HUD_REPLACEMENT_LAYER =
public static final Renderer2D.Deferred2DLayer VANILLA_HUD_REPLACEMENT_LAYER =
Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
static void renderHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter, CallbackInfo ci) {
public static void renderHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter, CallbackInfo ci) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) return;
@ -84,7 +84,7 @@ final class GuiHudBarHandler {
}
}
static void afterVanillaHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
public static void afterVanillaHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) return;
@ -97,7 +97,7 @@ final class GuiHudBarHandler {
renderCustomBarOnly(ctx, tickCounter, mc, bar);
}
static void renderHealth(GuiGraphicsExtractor context, Player player, int x, int y, float maxHealth, CallbackInfo ci) {
public static void renderHealth(GuiGraphicsExtractor context, Player player, int x, int y, float maxHealth, CallbackInfo ci) {
if (!Lifecycle.canRunHud()) return;
if (!(player instanceof LocalPlayer clientPlayer)) return;
Minecraft mc = Minecraft.getInstance();

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.hud;
package dev.loki.lovisual.mixinhandlers.hud;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
@ -20,18 +20,18 @@ import dev.loki.lovisual.util.text.format.TextJsonUtil;
* potion effects, crosshair, vignette and the portal/sleep overlays. Each entry point cancels the
* vanilla {@code extract*}/{@code display*} call when a module or custom HUD takes over.
*/
final class GuiOverlayHandler {
public final class GuiOverlayHandler {
private GuiOverlayHandler() {
}
static void hideScoreboardSidebar(CallbackInfo ci) {
public static void hideScoreboardSidebar(CallbackInfo ci) {
NoRender noRender = Modules.get(NoRender.class);
if ((noRender != null && noRender.hideScoreboard()) || Scoreboard.shouldReplaceVanilla()) {
ci.cancel();
}
}
static void filterOverlayMessage(Component message, boolean tinted, CallbackInfo ci) {
public static void filterOverlayMessage(Component message, boolean tinted, CallbackInfo ci) {
if (DebugLog.serverOnly()) {
if (message == null) {
// DebugLog.server("HUD overlay message: tinted=%s raw=null", tinted);
@ -51,7 +51,7 @@ final class GuiOverlayHandler {
}
}
static void hideVanillaPotions(CallbackInfo ci) {
public static void hideVanillaPotions(CallbackInfo ci) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) return;
if (!DraggableHudElementRegistry.isEnabled(Potions.class)) return;
@ -59,23 +59,23 @@ final class GuiOverlayHandler {
ci.cancel();
}
static void replaceCrosshair(CallbackInfo ci) {
public static void replaceCrosshair(CallbackInfo ci) {
Crosshair crosshair = Modules.get(Crosshair.class);
if (crosshair != null && crosshair.shouldHideVanilla()) {
ci.cancel();
}
}
static void killVignette(CallbackInfo ci) {
public static void killVignette(CallbackInfo ci) {
// Completely remove vanilla vignette post-processing
ci.cancel();
}
static void disablePortalOverlay(CallbackInfo ci) {
public static void disablePortalOverlay(CallbackInfo ci) {
ci.cancel();
}
static void disableSleepOverlay(CallbackInfo ci) {
public static void disableSleepOverlay(CallbackInfo ci) {
ci.cancel();
}
}

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.hud;
package dev.loki.lovisual.mixinhandlers.hud;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
@ -18,11 +18,11 @@ import dev.loki.lovisual.util.item.enchant.TopEnchantUtil;
* rarity / illegal / top-enchant derived name color and a fade driven by the highlight timer,
* cancelling the vanilla render when the custom SwapTooltip is active.
*/
final class GuiTooltipHandler {
public final class GuiTooltipHandler {
private GuiTooltipHandler() {
}
static void colorizeHeldItem(int toolHighlightTimer, ItemStack lastToolHighlight, Minecraft minecraft, CallbackInfo ci) {
public static void colorizeHeldItem(int toolHighlightTimer, ItemStack lastToolHighlight, Minecraft minecraft, CallbackInfo ci) {
if (!Lifecycle.canRunHud()) return;
SwapTooltip swapTooltip = SwapTooltip.get();
if (swapTooltip == null || !swapTooltip.isEnabled()) {

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.network.game;
package dev.loki.lovisual.mixinhandlers.network;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientPacketListener;
@ -30,7 +30,7 @@ import java.util.regex.Pattern;
* Chat, tab-metric and totem-pop packet bodies for {@link ClientPacketListenerMixin}.
* Physically under {@code handlers/} but declares the parent package for direct access.
*/
final class ClientPacketListenerPvpHandler {
public final class ClientPacketListenerPvpHandler {
// "TPS: 20.0" или "TPS: *20.0"
private static final Pattern TPS_PATTERN =
@ -42,7 +42,7 @@ final class ClientPacketListenerPvpHandler {
private ClientPacketListenerPvpHandler() {
}
static void captureTab(ClientboundTabListPacket packet) {
public static void captureTab(ClientboundTabListPacket packet) {
String header = packet.header() == null ? "" : packet.header().getString();
String footer = packet.footer() == null ? "" : packet.footer().getString();
ServerDumpUtil.dumpTabText("HEADER", packet.header());
@ -52,7 +52,7 @@ final class ClientPacketListenerPvpHandler {
}
}
static void onSystemChat(ClientboundSystemChatPacket packet) {
public static void onSystemChat(ClientboundSystemChatPacket packet) {
String msg = packet.content().getString();
// PvP state listeners via events; legacy direct setters disabled.
@ -61,7 +61,7 @@ final class ClientPacketListenerPvpHandler {
}
}
static void onPlayerChat(ClientboundPlayerChatPacket packet) {
public static void onPlayerChat(ClientboundPlayerChatPacket packet) {
String msg = packet == null ? null : packet.body().content();
if (msg == null || msg.isBlank()) return;
if (Events.BUS.hasListeners(PvpChatEvent.class)) {
@ -69,7 +69,7 @@ final class ClientPacketListenerPvpHandler {
}
}
static void parseTabMetrics(Component text) {
public static void parseTabMetrics(Component text) {
if (text == null) return;
String raw = text.getString();
@ -92,7 +92,7 @@ final class ClientPacketListenerPvpHandler {
}
}
static void onTotemStatus(ClientboundEntityEventPacket packet, ClientPacketListener listener) {
public static void onTotemStatus(ClientboundEntityEventPacket packet, ClientPacketListener listener) {
if (packet.getEventId() == 35) { // 35 = сработал тотем бессмертия
Minecraft client = Minecraft.getInstance();

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.network.game;
package dev.loki.lovisual.mixinhandlers.network;
import net.minecraft.client.Minecraft;
import net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket;
@ -22,12 +22,12 @@ import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectHeuristics;
* {@code @Inject}/{@code @Redirect} wiring. Physically under {@code handlers/} but
* declares the parent package for direct access.
*/
final class ClientPacketListenerSoundHandler {
public final class ClientPacketListenerSoundHandler {
private ClientPacketListenerSoundHandler() {
}
static void filterSound(ClientboundSoundPacket packet, CallbackInfo ci) {
public static void filterSound(ClientboundSoundPacket packet, CallbackInfo ci) {
NoSound module = Modules.get(NoSound.class);
if (module == null || !module.isEnabled()) return;
if (packet == null || packet.getSound() == null) return;
@ -37,7 +37,7 @@ final class ClientPacketListenerSoundHandler {
}
}
static void filterSoundFromEntity(ClientboundSoundEntityPacket packet, CallbackInfo ci) {
public static void filterSoundFromEntity(ClientboundSoundEntityPacket packet, CallbackInfo ci) {
NoSound module = Modules.get(NoSound.class);
if (module == null || !module.isEnabled()) return;
if (packet == null || packet.getSound() == null) return;
@ -47,7 +47,7 @@ final class ClientPacketListenerSoundHandler {
}
}
static void onPlaySound(ClientboundSoundPacket packet) {
public static void onPlaySound(ClientboundSoundPacket packet) {
SoundESP esp = Modules.get(SoundESP.class);
if (esp == null || !esp.isEnabled()) return;
if (packet == null || packet.getSound() == null) return;
@ -55,7 +55,7 @@ final class ClientPacketListenerSoundHandler {
esp.handleSound(packet.getSound().value().location(), pos);
}
static void onPlaySoundFromEntity(ClientboundSoundEntityPacket packet) {
public static void onPlaySoundFromEntity(ClientboundSoundEntityPacket packet) {
SoundESP esp = Modules.get(SoundESP.class);
if (esp == null || !esp.isEnabled()) return;
if (packet == null || packet.getSound() == null) return;
@ -68,7 +68,7 @@ final class ClientPacketListenerSoundHandler {
esp.handleSound(packet.getSound().value().location(), entity.position());
}
static void heuristicParticle(ClientboundLevelParticlesPacket packet) {
public static void heuristicParticle(ClientboundLevelParticlesPacket packet) {
var p = packet.getParticle();
// Log raw particles once per hook for diagnostics
DebugLog.info("Particle packet type=%s pos=%.2f %.2f %.2f count=%d",
@ -78,7 +78,7 @@ final class ClientPacketListenerSoundHandler {
StatusEffectHeuristics.handle(p, packet.getX(), packet.getY(), packet.getZ());
}
static void logStatusEffect(ClientboundUpdateMobEffectPacket packet) {
public static void logStatusEffect(ClientboundUpdateMobEffectPacket packet) {
DebugLog.server("Packet: add/refresh effect eid=%d effect=%s amp=%d dur=%d particles=%s icon=%s ambient=%s keepFading=%s",
packet.getEntityId(),
packet.getEffect().unwrapKey().map(k -> k.identifier().toString()).orElse("unknown"),
@ -91,7 +91,7 @@ final class ClientPacketListenerSoundHandler {
);
}
static void logRemove(ClientboundRemoveMobEffectPacket packet) {
public static void logRemove(ClientboundRemoveMobEffectPacket packet) {
DebugLog.server("Packet: remove effect eid=%d (%s) effect=%s",
packet.entityId(),
resolveName(packet.entityId()),

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.network.game;
package dev.loki.lovisual.mixinhandlers.network;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
@ -19,12 +19,12 @@ import java.util.UUID;
* staff tracker and totem-pop counter from spawn/despawn and player-info updates.
* Physically under {@code handlers/} but declares the parent package for direct access.
*/
final class ClientPacketListenerStaffHandler {
public final class ClientPacketListenerStaffHandler {
private ClientPacketListenerStaffHandler() {
}
static void onEntitiesDestroy(ClientboundRemoveEntitiesPacket packet) {
public static void onEntitiesDestroy(ClientboundRemoveEntitiesPacket packet) {
Minecraft mc = Minecraft.getInstance();
ClientLevel world = mc.level;
if (world == null) return;
@ -38,7 +38,7 @@ final class ClientPacketListenerStaffHandler {
}
}
static void onEntitySpawn(ClientboundAddEntityPacket packet) {
public static void onEntitySpawn(ClientboundAddEntityPacket packet) {
Minecraft mc = Minecraft.getInstance();
ClientLevel world = mc.level;
if (world == null) return;
@ -51,7 +51,7 @@ final class ClientPacketListenerStaffHandler {
StaffTracker.INSTANCE.onEntityAppear(player.getUUID(), player.getGameProfile().name());
}
static void onPlayerList(ClientboundPlayerInfoUpdatePacket packet) {
public static void onPlayerList(ClientboundPlayerInfoUpdatePacket packet) {
var actions = packet.actions();
boolean listedAffects = actions.contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED)
|| actions.contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER);
@ -77,7 +77,7 @@ final class ClientPacketListenerStaffHandler {
}
}
static void onPlayerRemove(ClientboundPlayerInfoRemovePacket packet) {
public static void onPlayerRemove(ClientboundPlayerInfoRemovePacket packet) {
for (UUID id : packet.profileIds()) {
// убрали из TAB

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.entity.player;
package dev.loki.lovisual.mixinhandlers.player;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
@ -10,14 +10,14 @@ import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.player.Input;
import net.minecraft.world.phys.Vec2;
final class LocalPlayerInputHandler {
public final class LocalPlayerInputHandler {
boolean shouldBlockWASD() {
public boolean shouldBlockWASD() {
Freecam fc = Modules.get(Freecam.class);
return fc != null && fc.isEnabled() && fc.isCameraInput();
}
void handleFreecamAfterInputTick(LocalPlayer self) {
public void handleFreecamAfterInputTick(LocalPlayer self) {
Freecam fc = Modules.get(Freecam.class);
if (fc == null || !fc.isEnabled() || !fc.isCameraInput()) return;
if (self.input == null) return;
@ -26,7 +26,7 @@ final class LocalPlayerInputHandler {
self.input.keyPresses = new Input(false, false, false, false, false, false, sprint);
}
void handleAfterInputTickSprint(LocalPlayer self) {
public void handleAfterInputTickSprint(LocalPlayer self) {
if (self.input == null) return;
Input original = self.input.keyPresses;
if (original == null) return;
@ -51,7 +51,7 @@ final class LocalPlayerInputHandler {
if (!sameInput(original, input)) applyPlayerInput(self, input);
}
void applyPlayerInput(LocalPlayer self, Input input) {
public void applyPlayerInput(LocalPlayer self, Input input) {
if (self.input == null || input == null) return;
InputAccessor accessor = (InputAccessor) self.input;
accessor.setPlayerInput(input);
@ -60,9 +60,9 @@ final class LocalPlayerInputHandler {
accessor.setMovementVector(new Vec2(sideways, forward).normalized());
}
Input transformDirection(Input input) { return input; }
public Input transformDirection(Input input) { return input; }
boolean postSprintControl(LocalPlayer self, boolean original, SprintControlEvent.Source source) {
public boolean postSprintControl(LocalPlayer self, boolean original, SprintControlEvent.Source source) {
if (self.input == null) return original;
if (!Events.BUS.hasListeners(SprintControlEvent.class)) return original;
Vec2 movement = self.input.getMoveVector();
@ -71,19 +71,19 @@ final class LocalPlayerInputHandler {
return event.shouldSprint();
}
boolean redirectShift(Input in, LocalPlayer self) {
public boolean redirectShift(Input in, LocalPlayer self) {
Freecam fc = Modules.get(Freecam.class);
if (fc != null && fc.isEnabled() && fc.isCameraInput() && self.getAbilities().flying) return false;
return in.shift();
}
boolean sameInput(Input a, Input b) {
public boolean sameInput(Input a, Input b) {
if (a == b) return true;
if (a == null || b == null) return false;
return a.forward() == b.forward() && a.backward() == b.backward() && a.left() == b.left() && a.right() == b.right() && a.jump() == b.jump() && a.shift() == b.shift() && a.sprint() == b.sprint();
}
float impulse(boolean positive, boolean negative) {
public float impulse(boolean positive, boolean negative) {
if (positive == negative) return 0.0f;
return positive ? 1.0f : -1.0f;
}

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.entity.player;
package dev.loki.lovisual.mixinhandlers.player;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.world.EventPushOutOfBlocks;
@ -11,7 +11,7 @@ import net.minecraft.world.entity.PortalProcessor;
import net.minecraft.world.phys.Vec3;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
final class LocalPlayerMovementHandler {
public final class LocalPlayerMovementHandler {
private float lastHealth = Float.NaN;
private boolean frozen;
@ -19,7 +19,7 @@ final class LocalPlayerMovementHandler {
private double savedX, savedY, savedZ;
private float savedBodyYaw, savedHeadYaw;
void onPortalNausea(LocalPlayer self, CallbackInfo ci) {
public void onPortalNausea(LocalPlayer self, CallbackInfo ci) {
PortalProcessor portal = null;
if (self instanceof EntityAccessor accessor) portal = accessor.lovisual$getPortalManager();
if (portal != null && portal.isInsidePortalThisTick()) {
@ -31,26 +31,26 @@ final class LocalPlayerMovementHandler {
}
}
void onHurt(float newHealth) {
public void onHurt(float newHealth) {
Freecam fc = Modules.get(Freecam.class);
if (fc == null || !fc.isEnabled() || !fc.disableOnDamage()) { lastHealth = newHealth; return; }
if (!Float.isNaN(lastHealth) && newHealth < lastHealth) fc.toggle();
lastHealth = newHealth;
}
void onPushOutOfBlocks(double x, double z, CallbackInfo ci) {
public void onPushOutOfBlocks(double x, double z, CallbackInfo ci) {
if (!Events.BUS.hasListeners(EventPushOutOfBlocks.class)) return;
EventPushOutOfBlocks event = new EventPushOutOfBlocks(x, z);
Events.BUS.post(event);
if (event.isCancelled()) ci.cancel();
}
boolean shouldFreezeNow() {
public boolean shouldFreezeNow() {
Freecam freecam = Modules.get(Freecam.class);
return freecam != null && freecam.freezePlayer() && freecam.isCameraInput();
}
void onFreezeTick(LocalPlayer self, CallbackInfo ci) {
public void onFreezeTick(LocalPlayer self, CallbackInfo ci) {
if (!shouldFreezeNow()) {
if (frozen) { self.setDeltaMovement(savedVelocity); frozen = false; }
return;

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.entity.player;
package dev.loki.lovisual.mixinhandlers.player;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.network.EventPostSync;
@ -8,13 +8,13 @@ import dev.loki.lovisual.util.combat.rotation.SprintController;
import net.minecraft.client.player.LocalPlayer;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
final class LocalPlayerNetworkHandler {
public final class LocalPlayerNetworkHandler {
private Runnable eventPostAction;
private EventSync activeSyncEvent;
private boolean postUpdateLock;
boolean onSendPositionHead(LocalPlayer self, CallbackInfo ci) {
public boolean onSendPositionHead(LocalPlayer self, CallbackInfo ci) {
if (!Events.BUS.hasListeners(EventSync.class)) return false;
eventPostAction = null;
EventSync event = new EventSync(self.getYRot(), self.getXRot());
@ -30,15 +30,15 @@ final class LocalPlayerNetworkHandler {
return false;
}
void onSendPositionReturn() {
public void onSendPositionReturn() {
if (Events.BUS.hasListeners(EventPostSync.class)) Events.BUS.post(new EventPostSync());
if (eventPostAction != null) { eventPostAction.run(); eventPostAction = null; }
activeSyncEvent = null;
}
EventSync getActiveSyncEvent() { return activeSyncEvent; }
public EventSync getActiveSyncEvent() { return activeSyncEvent; }
boolean onPostPlayerUpdate(LocalPlayer self, CallbackInfo ci, Runnable sendPosition) {
public boolean onPostPlayerUpdate(LocalPlayer self, CallbackInfo ci, Runnable sendPosition) {
if (!Events.BUS.hasListeners(PostPlayerUpdateEvent.class)) return false;
if (postUpdateLock) return false;
PostPlayerUpdateEvent event = new PostPlayerUpdateEvent();
@ -56,7 +56,7 @@ final class LocalPlayerNetworkHandler {
return true;
}
boolean shouldBlockSprintNetwork(boolean original) {
public boolean shouldBlockSprintNetwork(boolean original) {
if (SprintController.INSTANCE.isSprintBlocked()) return false;
return original;
}

View file

@ -1,4 +1,4 @@
package dev.loki.lovisual.mixins.entity.player;
package dev.loki.lovisual.mixinhandlers.player;
import dev.loki.lovisual.events.impl.network.EventSync;
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
@ -7,14 +7,14 @@ import dev.loki.lovisual.util.aiming.rotation.RotationManager;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.util.Mth;
final class LocalPlayerRotationHandler {
public final class LocalPlayerRotationHandler {
private double rotationPrevX;
private double rotationPrevZ;
private float rotationPrevBodyYaw;
private boolean rotationBodyYawInitialized;
void onTickCache(LocalPlayer self) {
public void onTickCache(LocalPlayer self) {
if (self instanceof ILocalPlayer access) {
access.lovisual$setLastYaw(self.getYRot());
access.lovisual$setLastPitch(self.getXRot());
@ -28,31 +28,31 @@ final class LocalPlayerRotationHandler {
}
}
float resolveSendYaw(LocalPlayer self, float original, EventSync active) {
public float resolveSendYaw(LocalPlayer self, float original, EventSync active) {
updateVisualBodyYaw(self);
if (active != null && active.hasRotationOverride()) return active.getYaw();
Rotation rotation = RotationManager.INSTANCE.getCurrentRotation();
return rotation != null ? rotation.yaw() : original;
}
float resolveSendPitch(float original, EventSync active) {
public float resolveSendPitch(float original, EventSync active) {
if (active != null && active.hasRotationOverride()) return Mth.clamp(active.getPitch(), -90.0f, 90.0f);
Rotation rotation = RotationManager.INSTANCE.getCurrentRotation();
return rotation != null ? Mth.clamp(rotation.pitch(), -90.0f, 90.0f) : original;
}
float resolveTickYaw(LocalPlayer self, float original) {
public float resolveTickYaw(LocalPlayer self, float original) {
updateVisualBodyYaw(self);
Rotation rotation = RotationManager.INSTANCE.getCurrentRotation();
return rotation != null ? rotation.yaw() : original;
}
float resolveTickPitch(float original) {
public float resolveTickPitch(float original) {
Rotation rotation = RotationManager.INSTANCE.getCurrentRotation();
return rotation != null ? Mth.clamp(rotation.pitch(), -90.0f, 90.0f) : original;
}
void updateVisualBodyYaw(LocalPlayer self) {
public void updateVisualBodyYaw(LocalPlayer self) {
Rotation rotation = RotationManager.INSTANCE.getCurrentRotation();
if (rotation == null) {
rotationPrevX = self.getX();
@ -67,7 +67,7 @@ final class LocalPlayerRotationHandler {
rotationPrevZ = self.getZ();
}
float simulateBodyYaw(float yaw, float prevBodyYaw, double prevX, double prevZ, double currentX, double currentZ, float handSwing) {
public float simulateBodyYaw(float yaw, float prevBodyYaw, double prevX, double prevZ, double currentX, double currentZ, float handSwing) {
double motionX = currentX - prevX;
double motionZ = currentZ - prevZ;
float motionSquared = (float) (motionX * motionX + motionZ * motionZ);

View file

@ -1,5 +1,9 @@
package dev.loki.lovisual.mixins.entity.player;
import dev.loki.lovisual.mixinhandlers.player.LocalPlayerInputHandler;
import dev.loki.lovisual.mixinhandlers.player.LocalPlayerRotationHandler;
import dev.loki.lovisual.mixinhandlers.player.LocalPlayerNetworkHandler;
import dev.loki.lovisual.mixinhandlers.player.LocalPlayerMovementHandler;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;

View file

@ -0,0 +1,58 @@
package dev.loki.lovisual.mixins.gui.chat;
import com.mojang.brigadier.suggestion.Suggestions;
import dev.loki.lovisual.Lifecycle;
import dev.loki.lovisual.features.command.core.CommandManager;
import dev.loki.lovisual.features.command.util.CommandPrefix;
import dev.loki.lovisual.features.command.suggest.SuggestionPlan;
import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat;
import net.minecraft.client.gui.components.CommandSuggestions;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.util.FormattedCharSequence;
import org.spongepowered.asm.mixin.Final;
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;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* Baritone-style autocomplete for client commands in the vanilla chat: while the input starts
* with the command prefix, the popup lists our commands and arguments as you type.
* BetterChat draws its own popup, so it keeps its existing path.
*/
@Mixin(CommandSuggestions.class)
public abstract class CommandSuggestionsMixin {
@Shadow @Final private EditBox input;
@Shadow @Final private List<FormattedCharSequence> commandUsage;
@Shadow private CompletableFuture<Suggestions> pendingSuggestions;
@Shadow private CommandSuggestions.SuggestionsList suggestions;
@Shadow private boolean keepSuggestions;
@Shadow
public abstract void showSuggestions(boolean narrateFirstSuggestion);
@Inject(method = "updateCommandInfo", at = @At("HEAD"), cancellable = true)
private void lovisual$clientCommandSuggestions(CallbackInfo ci) {
if (!Lifecycle.isActive() || BetterChat.isActive()) return;
String text = input.getValue();
if (!CommandPrefix.isCommandLike(text)) return;
ci.cancel();
commandUsage.clear();
if (!keepSuggestions) {
input.setSuggestion(null);
suggestions = null;
}
SuggestionPlan plan = SuggestionPlan.of(CommandManager.suggest(text, input.getCursorPosition()));
if (plan.suggestions() != null) {
pendingSuggestions = CompletableFuture.completedFuture(plan.suggestions());
showSuggestions(false);
} else if (plan.ghostHint() != null && !keepSuggestions) {
input.setSuggestion(" " + plan.ghostHint());
}
}
}

View file

@ -7,7 +7,7 @@ import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import dev.loki.lovisual.features.gui.mainmenu.LoVisualMainMenuScreen;
import dev.loki.lovisual.features.gui.mainmenu.MainMenus;
@Mixin(TitleScreen.class)
public abstract class TitleScreenMixin {
@ -15,11 +15,11 @@ public abstract class TitleScreenMixin {
@Inject(method = "init", at = @At("HEAD"), cancellable = true)
private void lovisual$replaceTitleScreen(CallbackInfo ci) {
if (LoVisualMainMenuScreen.shouldUseVanillaTitleScreen()) return;
if (MainMenus.shouldUseVanillaTitleScreen()) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
if (ClientScreen.current() instanceof LoVisualMainMenuScreen) return;
ClientScreen.show(mc, new LoVisualMainMenuScreen());
if (MainMenus.isMainMenu(ClientScreen.current())) return;
ClientScreen.show(mc, MainMenus.create());
ci.cancel();
}
}

View file

@ -1,5 +1,9 @@
package dev.loki.lovisual.mixins.hud;
import dev.loki.lovisual.mixinhandlers.hud.GuiOverlayHandler;
import dev.loki.lovisual.mixinhandlers.hud.GuiHudBarHandler;
import dev.loki.lovisual.mixinhandlers.hud.GuiHealthLayoutHandler;
import dev.loki.lovisual.mixinhandlers.hud.GuiTooltipHandler;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;

View file

@ -1,5 +1,8 @@
package dev.loki.lovisual.mixins.network.game;
import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerSoundHandler;
import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerStaffHandler;
import dev.loki.lovisual.mixinhandlers.network.ClientPacketListenerPvpHandler;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.network.protocol.game.*;
import org.slf4j.Logger;

View file

@ -20,6 +20,7 @@ import org.spongepowered.asm.mixin.injection.ModifyVariable;
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.util.animation.hand.HandDynamics;
import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel;
import dev.loki.lovisual.util.animation.EquipProgressComposer;
@ -31,6 +32,8 @@ import dev.loki.lovisual.features.module.modules.visuals.scene.visual.Chams;
public abstract class ItemInHandRendererMixin {
@Unique private boolean lovisual$handPosePushed;
@Unique private boolean lovisual$scriptPosePushed;
@Unique private boolean lovisual$itemPosePushed;
@Inject(method = "tick", at = @At("HEAD"), cancellable = true)
private void lovisual$skipTickWithoutPlayer(CallbackInfo ci) {
@ -56,7 +59,11 @@ public abstract class ItemInHandRendererMixin {
HandAnimState handAnim = holdMyItems.handAnim();
if (handAnim == null) return;
lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices);
if (holdMyItems.dynamicsActive()) {
lovisual$handPosePushed = HandDynamics.apply(holdMyItems, handAnim, player, tickDelta, hand, item, matrices);
}
lovisual$scriptPosePushed = HandScripts.applyHandPose(
holdMyItems, player, hand, swingProgress, equipProgress, tickDelta, item, matrices);
}
@Inject(method = "submitArmWithItem", at = @At("RETURN"))
@ -65,27 +72,20 @@ public abstract class ItemInHandRendererMixin {
float swingProgress, ItemStack item, float equipProgress, PoseStack matrices,
SubmitNodeCollector queue, int light, CallbackInfo ci
) {
if (lovisual$scriptPosePushed) {
lovisual$scriptPosePushed = false;
matrices.popPose();
}
if (!lovisual$handPosePushed) return;
lovisual$handPosePushed = false;
matrices.popPose();
}
@Inject(
method = "submitArmWithItem",
at = @At("HEAD")
)
@Inject(method = "submitArmWithItem", at = @At("HEAD"))
private void lovisual$applyOffsets(
AbstractClientPlayer player,
float tickDelta,
float pitch,
InteractionHand hand,
float swingProgress,
ItemStack item,
float equipProgress,
PoseStack matrices,
SubmitNodeCollector queue,
int light,
CallbackInfo ci
AbstractClientPlayer player, float tickDelta, float pitch, InteractionHand hand,
float swingProgress, ItemStack item, float equipProgress, PoseStack matrices,
SubmitNodeCollector queue, int light, CallbackInfo ci
) {
ViewModel viewModel = Modules.get(ViewModel.class);
if (viewModel == null || !viewModel.isEnabled()) return;
@ -112,7 +112,7 @@ public abstract class ItemInHandRendererMixin {
ViewModel viewModel = Modules.get(ViewModel.class);
HoldMyItems holdMyItems = Modules.get(HoldMyItems.class);
boolean lowerEnabled = viewModel != null && viewModel.isEnabled();
boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled()
boolean curveEnabled = holdMyItems != null && holdMyItems.isEnabled() && holdMyItems.dynamicsActive()
&& !(hand == InteractionHand.OFF_HAND && !holdMyItems.offhandToo.get());
if (!lowerEnabled && !curveEnabled) return equipProgress;
return EquipProgressComposer.compose(
@ -146,6 +146,8 @@ public abstract class ItemInHandRendererMixin {
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);
if (viewModel == null || !viewModel.isEnabled()) return;
if (!ctx.firstPerson()) return;
@ -155,6 +157,16 @@ public abstract class ItemInHandRendererMixin {
}
}
@Inject(method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V",
at = @At("RETURN"))
private void lovisual$restoreItemPose(
LivingEntity entity, ItemStack stack, ItemDisplayContext ctx, PoseStack ms,
SubmitNodeCollector queue, int light, CallbackInfo ci) {
if (!lovisual$itemPosePushed) return;
lovisual$itemPosePushed = false;
ms.popPose();
}
@Inject(
method = "swingArm",
at = @At("HEAD"),

View file

@ -28,6 +28,9 @@ import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.render.gamerenderer.GameRendererProjectionHelper;
import dev.loki.lovisual.render.gamerenderer.GameRendererWorldEngine;
import dev.loki.lovisual.render.gamerenderer.GameRendererWorldPost;
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.BetterButtons;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.camera.AspectRatio;

Some files were not shown because too many files have changed in this diff Show more