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

@ -10,11 +10,8 @@
"@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3",
},
@ -306,12 +303,6 @@
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@ -378,8 +369,6 @@
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-i18next": ["react-i18next@17.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
@ -428,8 +417,6 @@
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],

View file

@ -18,11 +18,8 @@
"@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3"
},

15
frontend/public/llms.txt Normal file
View file

@ -0,0 +1,15 @@
# LoVisual
> LoVisual is a free Fabric mod for Minecraft with 80+ visual modules: wings, particles, a custom HUD, weather and 18 built-in themes. It is a visual mod, not a cheat client. This site hosts the download, a browser theme editor, a showcase of community configs and account tools. The site is available in Russian and English.
## Main pages
- [Home](https://visual.loki-code.dev/): what the mod does, live ClickGui and HUD demos
- [Download](https://visual.loki-code.dev/download): the mod jar and release notes
- [Themes](https://visual.loki-code.dev/themes): edit and preview ClickGui themes in the browser
- [Showcase](https://visual.loki-code.dev/showcase): configs and themes published by players
## Notes for crawlers
- [Sitemap](https://visual.loki-code.dev/sitemap.xml): list of public pages
- Account pages (`/account`, `/configs`, `/link`) require a login and contain nothing public.

View file

@ -1,6 +1,5 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { I18n, type Lang } from '@shared/i18n/core'
import { setGlobalI18n } from '@shared/i18n/react'
import { accountEn } from '@features/account/i18n/en'
import { accountRu } from '@features/account/i18n/ru'
import { authEn } from '@features/auth/i18n/en'
@ -24,56 +23,39 @@ import { themesRu } from '@features/themes/i18n/ru'
import { commonEn } from '@shared/i18n/common.en'
import { commonRu } from '@shared/i18n/common.ru'
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: 'ru' | 'en'): i18n {
const instance = i18next.createInstance()
instance
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng,
resources: {
ru: {
common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
/** Creates an i18n instance and makes it the global one. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: Lang): I18n {
const instance = new I18n(
{
ru: {
common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
// Browser language decides the default: only an actual `ru` locale gets Russian,
// every other system language falls back to English.
fallbackLng: 'en',
supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
})
document.documentElement.lang = instance.language
instance.on('languageChanged', (next) => {
document.documentElement.lang = next
})
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
},
lng,
)
setGlobalI18n(instance)
return instance
}

View file

@ -1,31 +0,0 @@
import type { accountRu } from '@features/account/i18n/ru'
import type { authRu } from '@features/auth/i18n/ru'
import type { clickguiRu } from '@features/clickgui/i18n/ru'
import type { configsRu } from '@features/configs/i18n/ru'
import type { deviceLinkRu } from '@features/device-link/i18n/ru'
import type { downloadRu } from '@features/download/i18n/ru'
import type { landingRu } from '@features/landing/i18n/ru'
import type { profileRu } from '@features/profile/i18n/ru'
import type { showcaseRu } from '@features/showcase/i18n/ru'
import type { themesRu } from '@features/themes/i18n/ru'
import type { commonRu } from '@shared/i18n/common.ru'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: {
common: typeof commonRu
auth: typeof authRu
landing: typeof landingRu
themes: typeof themesRu
clickgui: typeof clickguiRu
download: typeof downloadRu
deviceLink: typeof deviceLinkRu
account: typeof accountRu
configs: typeof configsRu
showcase: typeof showcaseRu
profile: typeof profileRu
// Later tasks extend this with their own feature namespaces.
}
}
}

View file

@ -1,5 +1,5 @@
import { api } from '@shared/api/client'
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { Button } from '@shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useNavigate } from 'react-router'
import { Button } from '@shared/ui/Button'
import { useSession } from '@features/auth/session'

View file

@ -1,5 +1,5 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { listAvatarHistory, type AvatarHistoryEntry } from '@features/account/api'
type Props = {

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Notice } from '@shared/ui/Notice'
import { useSession } from '@features/auth/session'
import {

View file

@ -1,6 +1,6 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { getI18n } from '@shared/i18n/react'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '@features/account/components/DeviceList'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useSession } from './session'

View file

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'

View file

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
const v = email.trim()

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'

View file

@ -1,5 +1,5 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { GuiAction, GuiState } from '../demo'
import { Icon } from '../parts/Glyphs'
import { type Category, CATEGORIES } from '../parts/modules'
@ -22,7 +22,7 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) {
const { t } = useTranslation('clickgui')
const titleId = `lv-gui-panel-${category.id}`
return (
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<div role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<header className="lv-gui-panel-head">
<h2 id={titleId} className="lv-gui-panel-title">
{category.name}
@ -51,6 +51,6 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) {
)
})}
</ul>
</section>
</div>
)
}

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { PRESETS } from '@features/themes/presets.generated'
import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '@features/themes/types'
import { Icon } from '../parts/Glyphs'

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { TextField } from '@shared/ui/text-field/TextField'
import { publish } from '../api'

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
import { ShareCode } from '@shared/ui/share-code/ShareCode'

View file

@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { DOWNLOAD_URL, formatSize } from './api'
import '../landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Markdown } from '@shared/ui/Markdown'
import { fetchReleases } from './api'

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { ButtonLink } from '@shared/ui/Button'
import { useSession } from '../auth/session'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import '@features/landing/styles/sections.css'

View file

@ -119,15 +119,6 @@ function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun)
ctx.fillStyle = '#ffe7b8'
ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2)
// Blocky clouds.
for (let c = 0; c < 7; c++) {
const cx = rnd() * w
const cy = h * (0.12 + rnd() * 0.3)
const u = 14
ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)`
const len = 4 + Math.floor(rnd() * 7)
for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1))
}
}
/**

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { GITHUB_RELEASES_URL } from '@features/download/api'
import '@features/landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { parseArgb } from '@features/themes/types'
@ -97,7 +97,7 @@ export function HudPlayground() {
ref={(el) => {
widgetRefs.current[w.id] = el
}}
role="button"
role="group"
tabIndex={0}
title={w.label}
aria-label={t('hud.dragAria', { name: w.label })}

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { CATEGORIES } from '@features/clickgui/parts/modules'
import '@features/landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { browse } from '@features/showcase/api'
import '@features/landing/styles/sections.css'

View file

@ -1,5 +1,5 @@
import { lazy, Suspense, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
import { PRESETS } from '@features/themes/presets.generated'

View file

@ -1,5 +1,5 @@
import { type MouseEvent, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ButtonLink } from '@shared/ui/Button'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { RollText } from '@shared/motion/RollText'

View file

@ -43,7 +43,7 @@ test('picking a swatch repaints the theme section: pressed state and the big the
test('holding a HUD widget switches the screen into edit mode until it is released', () => {
reduceMotion()
withTheme(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
const widget = screen.getByRole('group', { name: /FPS/ })
widget.setPointerCapture = () => {}
const grid = document.querySelector('.lv-hud-grid')
expect(grid).toHaveAttribute('data-on', 'false')
@ -60,11 +60,11 @@ test('holding a HUD widget switches the screen into edit mode until it is releas
test('"Put it back" restores the HUD after moving a widget', () => {
reduceMotion()
withTheme(<HudPlayground />)
const start = screen.getByRole('button', { name: /FPS/ }).style.left
fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: /FPS/ }).style.left).not.toBe(start)
const start = screen.getByRole('group', { name: /FPS/ }).style.left
fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' })
expect(screen.getByRole('group', { name: /FPS/ }).style.left).not.toBe(start)
fireEvent.click(screen.getByRole('button', { name: 'Вернуть как было' }))
expect(screen.getByRole('button', { name: /FPS/ }).style.left).toBe(start)
expect(screen.getByRole('group', { name: /FPS/ }).style.left).toBe(start)
})
test('module wall shows how many modules each category has; Visuals has the 52 the headline promises', () => {

View file

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { I18nextProvider } from 'react-i18next'
import { I18nProvider } from '@shared/i18n/I18nProvider'
import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/hero/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router'
@ -29,13 +29,13 @@ test('english locale renders the landing hero headline', async () => {
const i18n = initI18n('en')
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
render(
<I18nextProvider i18n={i18n}>
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent("you'll fall in love with")
})
@ -49,11 +49,11 @@ test('cloud section speaks in player terms in both languages', async () => {
] as const) {
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
const { unmount } = render(
<I18nextProvider i18n={initI18n(lng)}>
<I18nProvider i18n={initI18n(lng)}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
expect(await screen.findByRole('heading', { level: 2 })).toHaveTextContent(title)
unmount()
@ -66,11 +66,11 @@ test('hero download CTA goes to the page with both download buttons', async () =
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
// Explicit ru: an earlier test in this file re-inits the default instance in English.
render(
<I18nextProvider i18n={initI18n('ru')}>
<I18nProvider i18n={initI18n('ru')}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' })
expect(cta).toHaveAttribute('href', '/download')

View file

@ -26,17 +26,17 @@ afterEach(() => vi.unstubAllGlobals())
test('HudWidget moves by 8px with ArrowRight', () => {
reduceMotion()
withQuery(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
const widget = screen.getByRole('group', { name: /FPS/ })
const before = Number.parseInt(widget.style.left, 10)
fireEvent.keyDown(widget, { key: 'ArrowRight' })
expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
expect(Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
})
test('HudWidget stays inside the playfield bounds', () => {
reduceMotion()
withQuery(<HudPlayground />)
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' })
const left = Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10)
expect(left).toBeGreaterThanOrEqual(0)
expect(left).toBeLessThanOrEqual(680)
})

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ApiError } from '@shared/api/client'
import { Notice } from '@shared/ui/Notice'
import { ListingList } from '@features/showcase/components/ListingList'

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link, useLocation } from 'react-router'
import { Notice } from '@shared/ui/Notice'
import { ApiError } from '@shared/api/client'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { Notice } from '@shared/ui/Notice'
import { getListing } from '../api'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { copiesText } from '../copies'
import type { Listing } from '../types'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useSearchParams } from 'react-router'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
const RU_PLURAL = new Intl.PluralRules('ru')

View file

@ -1,5 +1,5 @@
import { type ReactNode, startTransition, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../presets.generated'
import type { ThemeEntry } from '../types'

View file

@ -1,5 +1,5 @@
import { type CSSProperties, memo, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { argbToCss, parseArgb } from '../../types'
import './fields.css'

View file

@ -1,5 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { cssAngle } from '@features/clickgui/themeVars'
import { argbToCss, type GradientSpec } from '../../types'
import { ColorField } from './ColorField'

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { parseArgb, type ThemeEntry } from '../../types'
import { fromProfileJson, toProfileJson, toShareHash } from '../model/codec'

View file

@ -1,5 +1,5 @@
import { type KeyboardEvent, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
import { ColorField } from '../fields/ColorField'
import { GradientField } from '../fields/GradientField'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { ThemeEntry } from '../../types'
type History = { undo: () => void; redo: () => void; reset: () => void; resetName: string; canUndo: boolean; canRedo: boolean }

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../../presets.generated'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ParticleField } from '@features/landing/effects/ParticleField'
import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { SlotGrid } from '@features/configs/components/SlotGrid'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { LinkDeviceForm } from '@features/device-link/LinkDeviceForm'
import { PageTitle } from '@shared/layout/PageTitle'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { DownloadButton } from '@features/download/DownloadButton'
import { ReleaseNotes } from '@features/download/ReleaseNotes'
import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { BreakableBlock } from '@shared/easter/BreakableBlock'
import { PageTitle } from '@shared/layout/PageTitle'

View file

@ -1,5 +1,5 @@
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link, useLocation } from 'react-router'
import { Backdrop } from '@features/clickgui/parts/Backdrop'
import '@features/clickgui/parts/clickgui.css'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { LoginForm } from '@features/auth/forms/LoginForm'
import { AuthScreen } from './AuthScreen'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { RegisterForm } from '@features/auth/forms/RegisterForm'
import { AuthScreen } from './AuthScreen'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ParticleField } from '@features/landing/effects/ParticleField'
import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { ListingList } from '@features/showcase/components/ListingList'

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ThemeEditor } from '@features/themes/editor/ThemeEditor'
import { fromShareHash } from '@features/themes/editor/model/codec'
import { PRESETS } from '@features/themes/presets.generated'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
import { ApiError } from './client'
const BY_STATUS = {

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import '@shared/motion/motion.css'
import './block.css'

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useKonami } from './useKonami'
export function KonamiEffect() {

View file

@ -0,0 +1,8 @@
import type { ReactNode } from 'react'
import { I18nContext } from './context'
import type { I18n } from './core'
/** Scopes an instance to a subtree (tests render the same page in both languages side by side). */
export function I18nProvider({ i18n, children }: { i18n: I18n; children: ReactNode }) {
return <I18nContext value={i18n}>{children}</I18nContext>
}

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
const LANGS = ['ru', 'en'] as const

View file

@ -0,0 +1,16 @@
import { createContext } from 'react'
import type { I18n } from './core'
export const I18nContext = createContext<I18n | null>(null)
let current: I18n | null = null
/** Registers the instance hooks fall back to when no provider is mounted (the app itself uses this). */
export function setGlobalI18n(instance: I18n): void {
current = instance
}
export function getI18n(): I18n {
if (!current) throw new Error('i18n is not initialised')
return current
}

View file

@ -0,0 +1,124 @@
/**
* Tiny i18n core (replaces i18next): namespaced dictionaries, `{{var}}` interpolation,
* CLDR plural suffixes (`key_one` / `key_few` / `key_other`), fallback to English.
* Typed keys are derived from the Russian dictionaries (English must match them).
*/
import type { accountRu } from '@features/account/i18n/ru'
import type { authRu } from '@features/auth/i18n/ru'
import type { clickguiRu } from '@features/clickgui/i18n/ru'
import type { configsRu } from '@features/configs/i18n/ru'
import type { deviceLinkRu } from '@features/device-link/i18n/ru'
import type { downloadRu } from '@features/download/i18n/ru'
import type { landingRu } from '@features/landing/i18n/ru'
import type { profileRu } from '@features/profile/i18n/ru'
import type { showcaseRu } from '@features/showcase/i18n/ru'
import type { themesRu } from '@features/themes/i18n/ru'
import type { commonRu } from '@shared/i18n/common.ru'
export type Lang = 'ru' | 'en'
export interface Resources {
common: typeof commonRu
auth: typeof authRu
landing: typeof landingRu
themes: typeof themesRu
clickgui: typeof clickguiRu
download: typeof downloadRu
deviceLink: typeof deviceLinkRu
account: typeof accountRu
configs: typeof configsRu
showcase: typeof showcaseRu
profile: typeof profileRu
}
export type Namespace = keyof Resources & string
type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
type Paths<T> = T extends string
? never
: { [K in keyof T & string]: T[K] extends string ? (K extends `${infer B}_${PluralSuffix}` ? B : K) : `${K}.${Paths<T[K]>}` }[keyof T & string]
export type KeyOf<N extends Namespace> = Paths<Resources[N]>
type Qualified = { [N in Namespace]: `${N}:${KeyOf<N>}` }[Namespace]
export type TOptions = { count?: number } & Record<string, string | number | undefined>
export type TFunction<N extends Namespace = 'common'> = (key: KeyOf<N> | Qualified, options?: TOptions) => string
type Dict = { [k: string]: string | Dict }
type Bundles = Record<Lang, Record<string, Dict>>
const STORAGE_KEY = 'lv_lang'
const listeners = new Set<() => void>()
function lookup(dict: Dict | undefined, path: string): string | undefined {
let node: string | Dict | undefined = dict
for (const part of path.split('.')) {
if (node === undefined || typeof node === 'string') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
/** Only an actual `ru` locale gets Russian; every other language falls back to English. */
export function detectLanguage(): Lang {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'ru' || saved === 'en') return saved
} catch {
// Storage blocked: fall through to the browser language.
}
return typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
}
export class I18n {
language: Lang
private readonly bundles: Bundles
constructor(bundles: Bundles, lng?: Lang) {
this.bundles = bundles
this.language = lng ?? detectLanguage()
document.documentElement.lang = this.language
}
private resolve(lng: Lang, ns: string, key: string, count?: number): string | undefined {
const dict = this.bundles[lng][ns]
if (count !== undefined) {
const plural = lookup(dict, `${key}_${new Intl.PluralRules(lng).select(count)}`) ?? lookup(dict, `${key}_other`)
if (plural !== undefined) return plural
}
return lookup(dict, key)
}
getFixedT<N extends Namespace = 'common'>(lng: Lang, ns: N | readonly N[]): TFunction<N> {
const first = (Array.isArray(ns) ? ns[0] : ns) as string
return ((rawKey: string, options: TOptions = {}) => {
const colon = rawKey.indexOf(':')
const [space, key] = colon === -1 ? [first, rawKey] : [rawKey.slice(0, colon), rawKey.slice(colon + 1)]
const text = this.resolve(lng, space, key, options.count) ?? this.resolve('en', space, key, options.count)
if (text === undefined) return key
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, name: string) => String(options[name] ?? ''))
}) as TFunction<N>
}
t: TFunction<'common'> = (key, options) => this.getFixedT(this.language, 'common')(key, options)
changeLanguage(lng: Lang): Promise<void> {
if (lng !== this.language) {
this.language = lng
document.documentElement.lang = lng
try {
localStorage.setItem(STORAGE_KEY, lng)
} catch {
// Private mode: the choice just lasts for this tab.
}
for (const notify of listeners) notify()
}
return Promise.resolve()
}
subscribe = (notify: () => void): (() => void) => {
listeners.add(notify)
return () => listeners.delete(notify)
}
}

View file

@ -0,0 +1,15 @@
import { useContext, useMemo, useSyncExternalStore } from 'react'
import { getI18n, I18nContext } from './context'
import type { I18n, Namespace, TFunction } from './core'
export type { Lang, TFunction } from './core'
export { getI18n, setGlobalI18n } from './context'
/** Returns `t` bound to the first namespace (others via `ns:key`) plus the instance; re-renders on language change. */
export function useTranslation<N extends Namespace = 'common'>(ns: N | readonly N[] = 'common' as N): { t: TFunction<N>; i18n: I18n } {
const i18n = useContext(I18nContext) ?? getI18n()
const language = useSyncExternalStore(i18n.subscribe, () => i18n.language)
const first = Array.isArray(ns) ? ns[0] : ns
const t = useMemo(() => i18n.getFixedT(language, first as N), [i18n, language, first])
return { t: t as TFunction<N>, i18n }
}

View file

@ -0,0 +1,48 @@
import { afterEach, expect, test, vi } from 'vitest'
import { I18n } from '../core'
const bundles = {
ru: { common: { hi: 'Привет, {{name}}', only_ru: 'только ру', n_one: '{{count}} шт', n_other: '{{count}} шт.' } },
en: { common: { hi: 'Hi, {{name}}', n_one: '{{count}} item', n_other: '{{count}} items' } },
} as never
afterEach(() => {
localStorage.clear()
vi.unstubAllGlobals()
})
test('interpolates variables and picks plural forms', () => {
const i18n = new I18n(bundles, 'en')
const t = i18n.getFixedT('en', 'common' as never) as (k: string, o?: object) => string
expect(t('hi', { name: 'Loki' })).toBe('Hi, Loki')
expect(t('n', { count: 1 })).toBe('1 item')
expect(t('n', { count: 4 })).toBe('4 items')
})
test('missing Russian keys fall back to English, unknown keys echo the key', () => {
const i18n = new I18n(bundles, 'ru')
const t = i18n.getFixedT('ru', 'common' as never) as (k: string) => string
expect(t('only_ru')).toBe('только ру')
expect(t('nope')).toBe('nope')
})
test('the saved language wins over the browser, and only ru browsers default to Russian', () => {
vi.stubGlobal('navigator', { language: 'ru-RU' })
expect(new I18n(bundles).language).toBe('ru')
localStorage.setItem('lv_lang', 'en')
expect(new I18n(bundles).language).toBe('en')
localStorage.clear()
vi.stubGlobal('navigator', { language: 'de-DE' })
expect(new I18n(bundles).language).toBe('en')
})
test('changeLanguage persists the choice and notifies subscribers', async () => {
const i18n = new I18n(bundles, 'en')
const seen = vi.fn()
const off = i18n.subscribe(seen)
await i18n.changeLanguage('ru')
expect(seen).toHaveBeenCalledOnce()
expect(localStorage.getItem('lv_lang')).toBe('ru')
expect(document.documentElement.lang).toBe('ru')
off()
})

View file

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { I18nProvider } from '@shared/i18n/I18nProvider'
import { useTranslation } from '@shared/i18n/react'
import { expect, test } from 'vitest'
import { initI18n } from '@app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
@ -14,10 +15,10 @@ test('switching language re-renders strings and sets <html lang>', async () => {
const user = userEvent.setup()
const i18n = initI18n('ru')
render(
<I18nextProvider i18n={i18n}>
<I18nProvider i18n={i18n}>
<Probe />
<LanguageSwitch />
</I18nextProvider>,
</I18nProvider>,
)
expect(screen.getByText('Войти')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'EN' }))

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Outlet } from 'react-router'
import { KonamiEffect } from '../easter/KonamiEffect'
import { Footer } from './Footer'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { GITHUB_REPO } from '@features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'

View file

@ -1,6 +1,6 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { getI18n } from '@shared/i18n/react'
import { afterEach, expect, test } from 'vitest'
import { routes } from '@app/routes'
import { renderApp } from '@test/render'

View file

@ -1,5 +1,5 @@
import { type RefObject, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { NavLink } from 'react-router'
import { usePresence } from '@shared/motion/usePresence'
import { LanguageSwitch } from '../../i18n/LanguageSwitch'

View file

@ -1,5 +1,5 @@
import { useSharedThumb } from '@shared/motion/useSharedThumb'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { NavLink } from 'react-router'
export type NavItem = { to: string; label: string }

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link, useLocation } from 'react-router'
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
import { MobileMenu } from './MobileMenu'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { useSession } from '@features/auth/session'
import type { Me } from '../../types'

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '../Button'
export function ShareCode({ code, label }: { code: string; label?: string }) {

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;

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