Compare commits

...

10 commits

Author SHA1 Message Date
6c641c4a94
feat(mod): P1 modules (AutoSprint, TotemCounter, EnchantGlint, ArmorColor, PlaceAnimation), CustomModels, KineticLyrics fix
- AutoSprint, TotemCounter, EnchantGlint (styles, texture repaint, scroll speed/scale mixin), ArmorColor (durability colouring, targets, pulse),
  PlaceAnimation (block slides in from the hand; chunk-mesh hiding incl. Sodium, moving-block render), aligned with evo behaviour
- CustomModels: Chicken, Rabbit and Verity (Blockbench) models for chosen players, bundled assets, render-state carrier + submit redirect
- KineticLyrics: fix track key wiped by reset() (lyrics never applied, lrclib hammered every tick); new LyricsFinder with ranked lrclib search,
  unsynced timing estimate, NetEase fallback and retry on busy services; tests for the pure parts
- fix mode()/modeSetting() misuse for new modules; TODO updated
2026-10-02 19:54:27 +02:00
60b4fedb29
feat(mod): align ElytraTrails and Blizzard with evo (real wing-tip matrices, ribbon styles, ground-aware snow, whiteout fog, hide vanilla weather), spec tests 2026-10-02 15:58:33 +02:00
9661ff638d
feat(mod): ElytraTrails and Blizzard reviewed and fixed, shared trail ribbon, spec-style tests 2026-10-02 14:26:41 +02:00
622984f619
docs(mod): note chat-service prod deploy 2026-10-02 14:13:26 +02:00
a4638d386d
fix(mod): SpatialGui transparent inventory (no dim overlay in capture, premultiplied composite) 2026-10-02 13:55:27 +02:00
e82efb74e7
feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history 2026-10-02 13:41:49 +02:00
8d9a2ad0d6
feat(mod): ProjectileTrails, KillEffect death modes (Shatter/Ascend/Explosion/Disintegrate), TNT fuse bar; compact TODO
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
2026-10-02 10:01:37 +02:00
483d6bfb93
docs(mod): detail evo port plan in TODO
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
2026-10-02 09:50:15 +02:00
e604a48c31
fix(mod): smaller module description plaque, tooltip auto-scale to GUI scale
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
2026-10-02 09:49:02 +02:00
0504be5b57
feat(mod): scripted HoldMyItems on LuaJ (hand/item/model scripts, addons, vanilla overrides, particles, camera)
Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
2026-10-02 09:47:30 +02:00
187 changed files with 9980 additions and 1180 deletions

View file

@ -19,6 +19,7 @@ GATEWAY_PORT=8080
ACCOUNTS_HTTP_URL=http://127.0.0.1:8081
ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
CONFIGS_HTTP_URL=http://127.0.0.1:8082
CHAT_HTTP_URL=http://127.0.0.1:8083
SITE_ORIGIN=http://localhost:5173
TRUST_PROXY=false
# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here;

17
backend/Cargo.lock generated
View file

@ -860,6 +860,23 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chat-service"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-test",
"common",
"dotenvy",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "chrono"
version = "0.4.45"

View file

@ -2,6 +2,7 @@
resolver = "2"
members = [
"accounts-service",
"chat-service",
"common",
"configs-service",
"gateway",
@ -9,5 +10,4 @@ members = [
# Planned members, added when their own implementation plan starts
# (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3):
# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2)
# "addons-registry" — addon marketplace backend (Подсистема 3)

View file

@ -17,9 +17,9 @@ backend/
configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов,
# share-коды, витрина publish/browse/detail/copy
# (Подсистема 1, часть 2)
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
# виджеты-телеметрия (Подсистема 2). Единственный
# сервис с WebSocket, а не только REST.
chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence
# (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия
# (Подсистема 2) позже, там будет WebSocket.
addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии,
# публикация/модерация (Подсистема 3)
```

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p accounts-service
FROM debian:trixie-slim

View file

@ -0,0 +1,23 @@
[package]
name = "chat-service"
version = "0.1.0"
edition = "2024"
[lib]
name = "chat_service"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
dotenvy = "0.15"
[dev-dependencies]
axum-test = "21"

View file

@ -0,0 +1,20 @@
# Build context must be backend/ (the workspace root).
FROM rust:1-trixie AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
protobuf-compiler pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p chat-service
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/chat-service /usr/local/bin/chat-service
ENTRYPOINT ["/usr/local/bin/chat-service"]

View file

@ -0,0 +1,47 @@
# chat-service
Подсистема 2 платформы (RPC-чат, друзья, presence, телеметрия виджетов). Сейчас реализован только **GUI presence**;
чат и друзья позже (WebSocket). Сервис без БД: состояние в памяти, живёт секунды.
## GUI presence (идея из evo `GuiPresence`, наш вариант)
Зачем: LoVisual-игроки видят друг у друга над рукой мини-копию открытого меню клиента (какая категория, куда наведён курсор,
поиск), и знают, кто вообще пользуется LoVisual (значок у ника). Без видео и без чужих данных: только положение курсора и флаги.
### Протокол
`POST /presence/gui` (через gateway, нужен device-токен `lvd_...`, gateway кладёт `x-lovisual-account-id`):
```json
{ "server": "play.example.com", "mc": "<uuid игрока>", "gui": "<base64>|null", "want": ["<uuid>", "..."], "publish": true }
```
Ответ: `{ "players": [ { "mc": "<uuid>", "gui": "<base64>|null", "age": 120 } ] }`, `age` в мс с последнего обновления.
- Один запрос и публикует состояние вызывающего, и возвращает состояние тех, кого он видит (`want`, не больше 40).
- `publish = false` (по умолчанию true): только чтение, вызывающий сам не появляется у других.
- `gui = null` значит «меню закрыто, но LoVisual запущен» (значок у ника). Запись живёт 6 с без обновления.
- Игроки разных серверов не видят друг друга (ключ `server`, нормализуется в нижний регистр, ≤64 символов).
- Частота клиента: раз в ~350 мс при открытом меню (пачка до 8 сэмплов курсора по 100 мс), раз в 2 с при закрытом.
Лимит на gateway: 6 запросов в секунду на аккаунт.
### Формат `gui` (36 байт максимум, base64 ≤48 символов; сервис его не разбирает, только проверяет алфавит и длину)
| байт | значение |
|---|---|
| 0 | версия = 1 |
| 1 | биты 0-3 категория (0..15), бит 4 открыта страница настроек, бит 5 поиск, бит 7 меню закрыто |
| 2 | индекс модуля под курсором в списке категории (255 = нет) |
| 3 | число сэмплов n (0..8) |
| 4+4i | сэмпл: x (0..255 по ширине меню), y (0..255 по высоте), бит 0 = зажата ЛКМ, прокрутка списка (0..255) |
### Безопасность
- Идентичность только из заголовка gateway; прямой доступ закрыт общим секретом (как у остальных сервисов).
- Привязка `mc uuid` к аккаунту: чужой аккаунт не может публиковать за занятый uuid (409), привязка снимается через 10 мин
простоя; аккаунт владеет одним uuid, смена альта освобождает прежний. Это защита от подделки, не доказательство владения
аккаунтом Minecraft: серверной верификации uuid нет.
- Размер тела ≤8 КБ, payload только `[A-Za-z0-9+/=]`, `want` ≤40.
### Структура
`src/presence/{payload.rs (валидация), store.rs (состояние + TTL + привязка), handlers.rs (sync)}`; тесты: модульные в `store.rs`,
`payload.rs`, интеграционные `tests/presence.rs`. Порт `CHAT_PORT` (8083), `INTERNAL_KEY` общий. В gateway маршрут `/presence/*`.
### Не сделано
Просмотр видео (`.watch` у evo), чат и друзья (WebSocket), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).

View file

@ -1,5 +0,0 @@
# chat-service (planned)
RPC chat, friends, presence, telemetry widgets (Подсистема 2). WebSocket, not
just REST — see `TODO.md` (Фаза 10, "Подсистема 2") and
`backend/STRUCTURE.md`. No implementation plan yet.

View file

@ -0,0 +1,45 @@
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct Config {
pub port: u16,
pub internal_key: String,
}
impl Config {
pub fn from_env() -> Result<Config> {
Ok(Config {
port: std::env::var("CHAT_PORT")
.unwrap_or_else(|_| "8083".into())
.parse()
.context("CHAT_PORT")?,
internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?,
})
}
pub fn validate(&self) -> Result<()> {
if self.internal_key.len() < 32 {
anyhow::bail!("INTERNAL_KEY must be at least 32 bytes");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_key_is_rejected() {
let c = Config {
port: 0,
internal_key: "short".into(),
};
assert!(c.validate().is_err());
let c = Config {
port: 0,
internal_key: "k".repeat(32),
};
assert!(c.validate().is_ok());
}
}

View file

@ -0,0 +1,27 @@
pub mod config;
pub mod presence;
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}};
use common::internal::{InternalKey, require_internal_key};
use config::Config;
use presence::store::Store;
use std::sync::Arc;
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
const MAX_BODY_BYTES: usize = 8 * 1024;
pub fn build_app(cfg: &Config) -> (Router, Arc<Store>) {
let store = Arc::new(Store::default());
let api = Router::new()
.route("/presence/gui", post(presence::handlers::sync))
.with_state(Arc::clone(&store))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(axum::middleware::from_fn_with_state(
InternalKey::new(cfg.internal_key.clone()),
require_internal_key,
));
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api);
(app, store)
}

View file

@ -0,0 +1,13 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cfg = chat_service::config::Config::from_env()?;
cfg.validate()?;
let (app, state) = chat_service::build_app(&cfg);
chat_service::presence::spawn_purger(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("chat-service listening on {}", cfg.port);
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,77 @@
use super::{
payload::{MAX_WANT, normalize_server, valid_payload},
store::{PublishError, Store},
};
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use common::internal::GatewayIdentity;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{sync::Arc, time::Instant};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct SyncRequest {
pub server: String,
pub mc: Uuid,
/// Base64 GUI payload, or null when this client has no menu open (still
/// marks the player as a LoVisual user for a few seconds).
#[serde(default)]
pub gui: Option<String>,
/// Minecraft uuids of nearby players the caller wants state for.
#[serde(default)]
pub want: Vec<Uuid>,
/// False for a read-only poll: the caller sees others but does not appear itself.
#[serde(default = "yes")]
pub publish: bool,
}
fn yes() -> bool {
true
}
#[derive(Serialize)]
pub struct PlayerState {
pub mc: Uuid,
pub gui: Option<String>,
pub age: u64,
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
/// One round trip both publishes the caller's state and returns the state of
/// the players the caller can see (same request as the mod's poll loop).
pub async fn sync(
State(store): State<Arc<Store>>,
id: GatewayIdentity,
Json(req): Json<SyncRequest>,
) -> Response {
let Some(server) = normalize_server(&req.server) else {
return error(StatusCode::BAD_REQUEST, "invalid server");
};
if req.want.len() > MAX_WANT {
return error(StatusCode::BAD_REQUEST, "too many players requested");
}
if req.gui.as_deref().is_some_and(|g| !valid_payload(g)) {
return error(StatusCode::BAD_REQUEST, "invalid gui payload");
}
let now = Instant::now();
if req.publish
&& let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now)
{
return error(StatusCode::CONFLICT, "player uuid is bound to another account");
}
let players: Vec<PlayerState> = store
.lookup(&server, &req.want, now)
.into_iter()
.filter(|s| s.mc != req.mc)
.map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms })
.collect();
Json(json!({ "players": players })).into_response()
}

View file

@ -0,0 +1,20 @@
//! GUI presence: lets LoVisual users see which client menu nearby LoVisual
//! users have open. State is in-memory and short-lived (a few seconds), never
//! persisted; see `backend/chat-service/PLAN.md`.
pub mod handlers;
pub mod payload;
pub mod store;
use std::{sync::Arc, time::{Duration, Instant}};
use store::Store;
pub fn spawn_purger(store: Arc<Store>) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
store.purge(Instant::now());
}
});
}

View file

@ -0,0 +1,46 @@
//! Validation of the opaque GUI payload and server key. The payload is a
//! base64 string produced by the mod (header + up to 8 cursor samples); the
//! service never decodes it, it only bounds what it stores and relays.
/// Header (4 bytes) + 8 samples x 4 bytes = 36 bytes = 48 base64 characters.
pub const MAX_PAYLOAD_CHARS: usize = 48;
pub const MAX_SERVER_CHARS: usize = 64;
pub const MAX_WANT: usize = 40;
pub fn valid_payload(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_PAYLOAD_CHARS
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
pub fn normalize_server(s: &str) -> Option<String> {
let s = s.trim().to_ascii_lowercase();
if s.is_empty() || s.chars().count() > MAX_SERVER_CHARS || s.chars().any(|c| c.is_control()) {
return None;
}
Some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_charset_and_length() {
assert!(valid_payload("AQEAAAA="));
assert!(!valid_payload(""));
assert!(!valid_payload("has space"));
assert!(!valid_payload("<script>"));
assert!(!valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS + 1)));
assert!(valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS)));
}
#[test]
fn server_is_normalised() {
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
assert_eq!(normalize_server(""), None);
assert_eq!(normalize_server("a\nb"), None);
assert_eq!(normalize_server(&"x".repeat(65)), None);
}
}

View file

@ -0,0 +1,188 @@
use super::payload::MAX_WANT;
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use uuid::Uuid;
/// A published payload stops being relayed after this long without a refresh.
pub const ENTRY_TTL: Duration = Duration::from_secs(6);
/// A Minecraft uuid stays claimed by an account this long after its last publish.
pub const CLAIM_TTL: Duration = Duration::from_secs(600);
#[derive(Debug, PartialEq, Eq)]
pub enum PublishError {
/// The uuid is currently claimed by a different account.
Claimed,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Seen {
pub mc: Uuid,
pub gui: Option<String>,
pub age_ms: u64,
}
struct Entry {
gui: Option<String>,
updated: Instant,
}
struct Claim {
account: Uuid,
seen: Instant,
}
#[derive(Default)]
struct Inner {
entries: HashMap<(String, Uuid), Entry>,
claims: HashMap<Uuid, Claim>,
by_account: HashMap<Uuid, Uuid>,
}
/// In-memory presence state. A uuid is bound to one account at a time so a
/// client cannot impersonate somebody else's menu; an account owns one uuid at
/// a time (switching alts releases the previous claim).
#[derive(Default)]
pub struct Store {
inner: Mutex<Inner>,
}
impl Store {
pub fn publish(
&self,
account: Uuid,
server: &str,
mc: Uuid,
gui: Option<String>,
now: Instant,
) -> Result<(), PublishError> {
let mut inner = self.inner.lock().expect("presence lock");
if let Some(claim) = inner.claims.get(&mc)
&& claim.account != account
&& now.duration_since(claim.seen) < CLAIM_TTL
{
return Err(PublishError::Claimed);
}
if let Some(previous) = inner.by_account.insert(account, mc)
&& previous != mc
{
inner.claims.remove(&previous);
inner.entries.retain(|(_, id), _| *id != previous);
}
inner.claims.insert(mc, Claim { account, seen: now });
inner
.entries
.insert((server.to_owned(), mc), Entry { gui, updated: now });
Ok(())
}
/// Fresh entries for the requested uuids on this server (bounded).
pub fn lookup(&self, server: &str, want: &[Uuid], now: Instant) -> Vec<Seen> {
let inner = self.inner.lock().expect("presence lock");
want.iter()
.take(MAX_WANT)
.filter_map(|mc| {
let entry = inner.entries.get(&(server.to_owned(), *mc))?;
let age = now.duration_since(entry.updated);
(age < ENTRY_TTL).then(|| Seen {
mc: *mc,
gui: entry.gui.clone(),
age_ms: age.as_millis() as u64,
})
})
.collect()
}
pub fn purge(&self, now: Instant) {
let mut inner = self.inner.lock().expect("presence lock");
inner.entries.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
let expired: Vec<Uuid> = inner
.claims
.iter()
.filter(|(_, c)| now.duration_since(c.seen) >= CLAIM_TTL)
.map(|(mc, _)| *mc)
.collect();
for mc in expired {
if let Some(claim) = inner.claims.remove(&mc) {
inner.by_account.remove(&claim.account);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
(Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(10), Uuid::from_u128(20))
}
#[test]
fn published_entry_is_visible_then_expires() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
assert_eq!(seen[0].age_ms, 500);
assert!(store.lookup("srv", &[mc], t0 + ENTRY_TTL).is_empty());
}
#[test]
fn entries_are_scoped_to_the_server() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "a", mc, None, t0).unwrap();
assert!(store.lookup("b", &[mc], t0).is_empty());
assert_eq!(store.lookup("a", &[mc], t0).len(), 1);
}
#[test]
fn other_account_cannot_take_a_claimed_uuid() {
let (acc, other, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
assert_eq!(store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)), Err(PublishError::Claimed));
// after the claim goes idle it can be taken over
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
#[test]
fn switching_uuid_releases_the_previous_claim() {
let (acc, other, mc1, mc2) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc1, None, t0).unwrap();
store.publish(acc, "s", mc2, None, t0).unwrap();
assert!(store.lookup("s", &[mc1], t0).is_empty());
assert!(store.publish(other, "s", mc1, None, t0).is_ok());
}
#[test]
fn lookup_is_bounded() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
let mut want: Vec<Uuid> = (100..100 + MAX_WANT as u128).map(Uuid::from_u128).collect();
want.push(mc);
assert!(store.lookup("s", &want, t0).is_empty());
}
#[test]
fn purge_drops_stale_state() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
store.purge(t0 + CLAIM_TTL);
assert!(store.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
}

View file

@ -0,0 +1,71 @@
use axum_test::TestServer;
use chat_service::config::Config;
use common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
use serde_json::{Value, json};
use uuid::Uuid;
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
fn server() -> TestServer {
let cfg = Config { port: 0, internal_key: KEY.into() };
let (app, _) = chat_service::build_app(&cfg);
TestServer::new(app)
}
async fn sync(s: &TestServer, account: u128, body: Value) -> axum_test::TestResponse {
s.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, Uuid::from_u128(account).to_string())
.json(&body)
.await
}
#[tokio::test]
async fn requires_internal_key_and_identity() {
let s = server();
let r = s.post("/presence/gui").json(&json!({})).await;
assert_eq!(r.status_code(), 403);
let r = s
.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.json(&json!({"server": "a", "mc": Uuid::nil()}))
.await;
assert_eq!(r.status_code(), 401);
}
#[tokio::test]
async fn two_players_see_each_other() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
let r = sync(&s, 1, json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]})).await;
assert_eq!(r.status_code(), 200);
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
let r = sync(&s, 2, json!({"server": "play.x", "mc": b, "gui": null, "want": [a]})).await;
let body = r.json::<Value>();
let players = body["players"].as_array().unwrap();
assert_eq!(players.len(), 1);
assert_eq!(players[0]["mc"], a.to_string());
assert_eq!(players[0]["gui"], "AQEAAAA=");
}
#[tokio::test]
async fn rejects_bad_input_and_uuid_theft() {
let s = server();
let mc = Uuid::from_u128(10);
let r = sync(&s, 1, json!({"server": "x", "mc": mc, "gui": "<bad>"})).await;
assert_eq!(r.status_code(), 400);
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
assert_eq!(r.status_code(), 400);
assert_eq!(sync(&s, 1, json!({"server": "x", "mc": mc})).await.status_code(), 200);
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
assert_eq!(r.status_code(), 409);
}
#[tokio::test]
async fn read_only_poll_does_not_publish() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
sync(&s, 1, json!({"server": "x", "mc": a, "publish": false, "want": [b]})).await;
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
}

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p configs-service
FROM debian:trixie-slim

View file

@ -97,6 +97,16 @@ services:
CONFIGS_PORT: 8082
networks: [lovisual-internal]
chat-service:
build:
context: .
dockerfile: chat-service/Dockerfile
restart: unless-stopped
env_file: .env
environment:
CHAT_PORT: 8083
networks: [lovisual-internal]
gateway:
build:
context: .
@ -107,12 +117,15 @@ services:
condition: service_started
configs-service:
condition: service_started
chat-service:
condition: service_started
env_file: .env
environment:
GATEWAY_PORT: 8080
ACCOUNTS_HTTP_URL: http://accounts-service:8081
ACCOUNTS_GRPC_URL: http://accounts-service:50051
CONFIGS_HTTP_URL: http://configs-service:8082
CHAT_HTTP_URL: http://chat-service:8083
# Read-only static files served under GET /downloads/* (lovisual.jar).
DOWNLOADS_DIR: /srv/downloads
volumes:

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p gateway
FROM debian:trixie-slim

View file

@ -8,6 +8,7 @@ pub struct Config {
pub accounts_http_url: String,
pub accounts_grpc_url: String,
pub configs_http_url: String,
pub chat_http_url: String,
pub site_origin: String,
/// Directory served read-only under `GET /downloads/*` — today just
/// `lovisual.jar`, the mod's direct download (see TODO.md «Скачивание»).
@ -33,6 +34,7 @@ impl Config {
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
configs_http_url: var("CONFIGS_HTTP_URL")?,
chat_http_url: var("CHAT_HTTP_URL")?,
site_origin: var("SITE_ORIGIN")?,
downloads_dir: std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "downloads".into()),
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
@ -71,6 +73,7 @@ mod tests {
accounts_http_url: String::new(),
accounts_grpc_url: String::new(),
configs_http_url: String::new(),
chat_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
downloads_dir: "downloads".into(),
trust_proxy: false,

View file

@ -31,6 +31,7 @@ pub struct Upstreams {
pub client: reqwest::Client,
pub accounts: String,
pub configs: String,
pub chat: String,
pub internal_key: HeaderValue,
}
@ -44,6 +45,7 @@ impl Upstreams {
.build()?,
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
chat: cfg.chat_http_url.trim_end_matches('/').to_owned(),
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
})
}
@ -67,6 +69,7 @@ pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
let base = match target {
Upstream::Accounts => &up.accounts,
Upstream::Configs => &up.configs,
Upstream::Chat => &up.chat,
};
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
let url = format!("{base}{path_and_query}");

View file

@ -2,12 +2,14 @@
pub enum Upstream {
Accounts,
Configs,
Chat,
}
pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "media" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs),
"presence" => Some(Upstream::Chat),
_ => None,
}
}
@ -26,6 +28,7 @@ mod tests {
);
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
assert_eq!(upstream_for("/presence/gui"), Some(Upstream::Chat));
assert_eq!(upstream_for("/authx"), None);
assert_eq!(upstream_for("/"), None);
assert_eq!(upstream_for("/health"), None);

View file

@ -60,6 +60,8 @@ pub fn rules() -> Vec<Rule> {
),
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
// The mod syncs menu presence about 3 times a second while a menu is open.
rule(Method::POST, "/presence/*", Quota::per_second(n(6)), Account),
]
}

View file

@ -20,6 +20,7 @@ pub fn config(accounts: &str, configs: &str) -> Config {
accounts_http_url: accounts.into(),
accounts_grpc_url: String::new(),
configs_http_url: configs.into(),
chat_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
downloads_dir: std::env::temp_dir()
.join("lovisual-downloads-test")

View file

@ -3,7 +3,7 @@
# poking and scripts/e2e.sh. Restarts them if they are already running.
#
# Prerequisites (one-time):
# cargo build -p accounts-service -p configs-service -p gateway
# cargo build -p accounts-service -p configs-service -p chat-service -p gateway
# a Postgres with accounts_db + configs_db, and MinIO with the
# lovisual-avatars-test bucket (see backend/PLAN.md, Task 2).
# Dev-only secrets below are fine locally; production reads backend/.env.
@ -25,21 +25,22 @@ export COOKIE_SECURE="false"
# browser-facing avatar prefix served through the gateway (mirrors prod /api/media)
export AVATAR_PUBLIC_BASE_URL="http://127.0.0.1:8080/media"
pkill -f 'target/debug/(accounts-service|configs-service|gateway)' 2>/dev/null || true
pkill -f 'target/debug/(accounts-service|configs-service|chat-service|gateway)' 2>/dev/null || true
sleep 1
PORT=8081 GRPC_PORT=50051 setsid nohup "$B/target/debug/accounts-service" >"$L/accounts.log" 2>&1 &
CONFIGS_PORT=8082 setsid nohup "$B/target/debug/configs-service" >"$L/configs.log" 2>&1 &
CHAT_PORT=8083 setsid nohup "$B/target/debug/chat-service" >"$L/chat.log" 2>&1 &
GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL="http://127.0.0.1:8081" \
CONFIGS_HTTP_URL="http://127.0.0.1:8082" SITE_ORIGIN="http://localhost:5173" \
CONFIGS_HTTP_URL="http://127.0.0.1:8082" CHAT_HTTP_URL="http://127.0.0.1:8083" SITE_ORIGIN="http://localhost:5173" \
setsid nohup "$B/target/debug/gateway" >"$L/gateway.log" 2>&1 &
up() { [ "$(curl -sf "$1" 2>/dev/null || true)" = "ok" ]; }
for i in $(seq 1 60); do
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8080/health && break
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8083/health && up http://127.0.0.1:8080/health && break
sleep 0.5
done
for u in 8081 8082 8080; do
for u in 8081 8082 8083 8080; do
up "http://127.0.0.1:$u/health" || { echo "port $u did not come up"; tail -5 "$L"/*.log; exit 1; }
done
echo "accounts+configs+gateway up"
echo "accounts+configs+chat+gateway up"

View file

@ -1,739 +1,136 @@
# LoVisual Mod (combatant-client-26.2) — план переписи
Мод — форк Combatant Client, переписываем под LoVisual.
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
- **Макс. 200 строк на файл** (было >1500) — превышение = разбить на модули
- **Макс. 4 файла на папку** — больше = новая подпапка по смыслу
- **Публичный API можно менять свободно** (решение владельца 2026-09-05):
KISS важнее обратной совместимости; при рефакторинге разрешено менять
сигнатуры, удалять делегирование и мёртвый код, обновляя вызывающих
- **Коммиты — на английском** (решение 2026-09-23, действует с этого момента
вперёд; более старые коммиты на русском не переписываем)
- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле
- **Никаких this-escape** — конструктор не публикует `this`
- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel
- **Без пустышек** — файл без реализации не создаётся
- **Каждый этап компилируется** — `./gradlew build` перед переходом к следующему
- **Тесты обязательны** для новых модулей (JUnit)
---
## СТАТУС ПРОБЛЕМ (что вижу сейчас)
### 1. Ребрендинг не закончен (пункт 2)
- 288 файлов / **1464 упоминания** `combatant` в main-коде
- Классы: `CombatantRenderSystem`, `CombatantRenderEngineBootstrap`, `CombatantRenderPipelines`, `CombatantVulkanBackend`, `CombatantFrameGraph`, `CombatantTerrainVertex` и др.
- Заголовки файлов: «This file is part of the Combatant Client distribution» / копирайт изначального автора
### 2. Файлы-гиганты (пункт 3) — 12 файлов > 1500 строк
| Файл | Строк |
|------|-------|
| render/engine/renderer/Renderer2D.java | 3379 |
| features/gui/chat/BetterChatRenderer.java | 2870 |
| features/gui/clickgui/settings/UnifiedSettingRenderer.java | 2798 |
| features/module/modules/player/Scaffold.java | 2416 |
| features/module/modules/combat/AttributeSwap.java | 1922 |
| features/gui/clickgui/ClickGuiRenderer.java | 1900 |
| features/module/modules/visuals/NameTags.java | 1791 |
| features/module/modules/combat/ElytraTarget.java | 1711 |
| features/gui/hud/draggable/impl/TargetHud.java | 1687 |
| features/module/modules/visuals/ESP.java | 1520 |
| features/module/modules/combat/AutoCrystal.java | 1516 |
| features/gui/hud/nondraggable/impl/CustomBar.java | 1496 |
### 3. Runtime / JarReplacement (пункт 4)
- `runtime/` (ClientRuntime, RuntimeGate, ClientRuntimeState, SOFT_PANIC/JAR_REPLACEMENT_PANIC, shutdown/resume-участники)
- `runtime/loader/` (JarReplacementCoordinator, LoaderBridge, PanicOnlyJarReplacementCoordinator)
- **Моё предложение: выкинуть.** Живая замена jar в памяти — источник classloader-утечек, хрупкая, для клиента не нужна (обновление делает лаунчер). Заменить на простой Lifecycle.
### 4. Security (пункт 7)
- `features/security/BackdoorProtection` — на деле защита от SSRF/translate-инъекций (порт ExploitPreventer, MIT). Полезное, но `enum`-синглтон со статикой.
- `features/relations/StaffTracker` + `StaffHeuristicsMatcher` — детект «стафа» по эвристикам TAB. Сомнительно, решить оставить/выкинуть.
### 5. Тесты (пункт 6)
- 5 тестов на ~213k строк. Добавлять по мере переписи модулей.
---
## Фаза 1: Ребрендинг (пункт 2)
- [x] **Восстановлен `config/`-пакет** (55+3 файла) из `ref/zip/combatant-client-26.2 (1).zip` — он отсутствовал в форке, без него проект не собирался. Переименован `combatant.client.config` → `dev.loki.lovisual.config`. `compileJava` ✓
- [x] Заменить `combatant` → `lovisual` в main-коде (1464 вхождения, 288 файлов)
- [x] Переименовать классы `Combatant*` → `LoVisual*` (23 шт., включая `IrisCombatantFrameHooks` → `IrisLoVisualFrameHooks`)
- [x] Заголовки файлов → LoVisual, пересобрать копирайты (GPLv3 форка)
- [x] Проверить `fabric.mod.json` / `lovisual.mixins.json` на остатки
- [x] Проверить строки/сообщения/логи на «combatant»
- [x] JNI-имена в native: `Java_combatant_client_` → `Java_dev_loki_lovisual_` (34 шт.) + FindClass-пути
### Проверка: `rg -i combatant src` → 0 ✓, `./gradlew build` ✓ (1406 файлов изменено)
## Фаза 2: Выкинуть Runtime/JarReplacement (пункт 4)
> **Вместе с рантаймом удалить и модуль Panic** (`features/module/modules/misc/Panic.java`),
> интерфейс `RuntimeControlModule`, команду `RuntimeCommand` и все ветки на них
> в `ModuleManager`/`Module`. Panic — единственная реализация `RuntimeControlModule`,
> без него они становятся мёртвым кодом.
- [x] Удалить `runtime/` и `runtime/loader/` (14 файлов)
- [x] Удалить `Panic.java` + `RuntimeControlModule.java` + `RuntimeCommand.java`
- [x] Убрать ветки `instanceof RuntimeControlModule` и `tickRuntimeControllers()` из ModuleManager/Module
- [x] Убрать `RuntimeGate` из точек входа (LoVisual.java, модули, рендер) — заменён на `Lifecycle`
- [x] Заменить на простой Lifecycle-класс (`dev.loki.lovisual.Lifecycle`: INIT → ACTIVE → SHUTDOWN, гейты `canRunRender/Hud/ShaderBridge` + `RenderResourceReadiness`)
- [x] Panic-фичи удалены; `ModuleManager.disableAll()` оставлен как простая утилита
- [x] Мёртвые cleanup-методы удалены (`shutdownForRuntime`, `clearRuntimeReferences`, `VanillaChatRuntimeCleanup`, i18n-ключи panic/runtime)
### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0
## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА
> Стратегия: публичный фасад исключён из лимита ≤200 (делегирует ~все публичные методы),
> вся логика вынесена в package-private final-хелперы в том же пакете (плоско),
> ≤200 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/
> AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно.
- [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors,
Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures)
- [x] BetterChatRenderer (2870) → фасад 210 + 21 хелпер (LayoutEngine, Viewport, FrameRenderer,
MessageRenderer, GlyphRenderer, InputRenderer, Suggestion*, InputHandler, MouseClick,
ContextMenu, Tooltip, Draw/Font/EditBox/Segment/CommandMerge + 3 model-файла)
- [x] UnifiedSettingRenderer (2798) → фасад 208 + 24 хелпера (по типам настроек + цвет-редактор
из 9 классов: Renderer, SquareRenderer, PresetRenderer/Handler, HexInput/State, Drag, Layout, Math)
- [x] ClickGuiRenderer (1887) → фасад 542 + 15 хелперов (Input, Keyboard, RenderEngine, Lifecycle,
Screen, TextBatch, DrawHelpers, KeyBind, Editor, PickerIcon, TopLayer, TabBar, TabState + Model + Fonts)
- [x] NameTags (1791) → фасад 438 + 11 хелперов (Renderer, Plate, LayoutEngine, Face, Equipment,
Opponent, Effects, LabelBuilder, EnchantFormatter, Cache + Model)
- [x] TargetHud (1655) → фасад 340 + 11 хелперов (Renderer, TargetResolver, Panel, Face, Content,
Text, Arc, Equipment, Effects, Formatter + Model)
- [x] ESP (1538) → фасад 269 + 11 хелперов (EntityCollector, ShaderRenderer/Mask/Color, 2D/3D,
SearchOverlay, Outline, MeshBuilder, ColorUtil + Model)
- [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model)
### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤200, фасады исключены;
### 0 вызывающих файлов изменено во всей Фазе 3
### Долг (файлы >200 вне списка гигантов, в т.ч. новые фасады):
### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449,
### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116,
### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042,
### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441,
### а также многие HUD/module/rendering-файлы 200–800 строк. Разбирать по желанию.
## Фаза 3.5: Удаление читерских модулей (легит-направление)
> Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются.
- [x] combat: удалены AttributeSwap, AutoAnchor, AutoAttack, AutoBed, AutoBow, AutoCrystal, Backtrack,
Criticals, ElytraTarget, Hitbox, KillAura, KTLeave, MaceKill. **Оставлены:** ProjectilePuncher,
PvpCooldowns, Reach, RubberHand, SpearAssist, TPSSync
- [x] movement: удалены ВСЕ модули (AirJump, AutoDodge, ElytraFly, ElytraHelper, Flight, Freeze,
InventoryMove, NoFall, NoPush, NoStun, Parkour, Phase, ReverseStep, SafeWalk, Speed, Sprint,
Step, Strafe, SuperFirework, TargetStrafe, Timer, Velocity, WindJump) — 22 файла
- [x] player: удалены AirPlace, AutoArmor, AutoEat, AutoTotem, Scaffold, XCarry.
**Оставлены:** AutoTool, ClickPearl, FakeLag, NoDelay, NoInteract, ShitDropper
- [x] Осиротевшие хелпер-пакеты: удалены `combat/autocrystal/` (17), `combat/autobed/` (9),
`combat/autoanchor/` (9), `util/block/scaffold/` (10), `BlockPlacer` + тест ScaffoldPlacementGateTest
- [x] Enum-заглушки: `KillAura.RaycastMode/RotationTiming/AngleSmoothMode` → локальные enum в
ProjectilePuncher/Reach; `Criticals.SelectionMode` → удалено; `Hitbox`-зависимость убрана из ESP
- [x] ESP переписан под легит: новые настройки `only_visible` (LOS-луч от камеры), `skip_invisible`,
`only_armor` (без брони не подсвечивать) — по умолчанию `true`, только для игроков
### Проверка: `./gradlew build` ✓, тесты ✓ (13), остатки только в комментариях/строках
## Фаза 4: Security (пункт 7)
- [x] BackdoorProtection: убрать `enum`-синглтон, сделать инстансный сервис, ленивый `Minecraft.getInstance()`
- [x] Решить судьбу StaffTracker/StaffHeuristics — согласовать и выкинуть или переписать
(решение: StaffHeuristics удалён, StaffTracker+StaffData оставлены, переведены на INSTANCE-паттерн)
- [x] Покрыть логику фильтров тестами (BackdoorProtectionTest, 11 тестов)
## Фаза 5: Модули — нормализация под правила
- [x] Прочесать все 131 модуль: убрать `static final Minecraft` в полях (6 шт. уже найдено)
(LegacyHudNotifier, ScissorFunction, ShitDropper, ProtocolUtil, CooldownRender, ClickGuiRenderer.MC
+ 7 файлов-потребителей ClickGui-пакета — всё переведено на ленивый `Minecraft.getInstance()`)
- [x] Проверить модули на `System.currentTimeMillis()` → заменить на игровое время
(16 файлов модулей → новый `util/time/GameClock.millis()`, откат на стенное время вне мира)
- [ ] Разнести addon/events/mixininterface по смыслу — ОТЛОЖЕНО: blast-radius 54/41/88 файлов-импортёров,
чистый import-churn без функциональной ценности; делать только отдельным скриптовым проходом
## Фаза 6: Тесты (пункт 6)
- [x] Юнит-тесты: EventBus (12), конфиг (values: NumberValue/Boolean/String/Set/ItemIdSet/BooleanMap,
ModeValue, EnumValue, RGB/RGBA color; profile: DiffEngine, ValueCodec),
утилиты (LegacyTextUtil — 12). Игровые классы без моков: тесты только на чистую логику.
- [x] По мере переписи модулей — тест на каждый (Текущий итог: 292 теста, все зелёные)
## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем
> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи,
> переписываем под наши правила; у Delta 114 модулей, у нас 63).
> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog,
> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага,
> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src.
### Решения по обсуждению (2026-09-05)
- [x] Wasted — НЕ делаем
- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт)
- [x] DeathCoords — делаем
- [x] ItemScroller / Arrows — НЕ делаем
- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты)
- [x] Правило папок: максимум 4 файла (было 3)
### Перенос (список пользователя)
- [x] **StreamerMode** (misc) — маскирует ники-подобные токены и названия серверов в чате
(общая точка NameProtect: ChatComponentMixin.addMessage), скины заменяются на
стандартные (AbstractClientPlayerMixin.getSkin). Чистая логика — `util/text/NameMasker`
(тест NameMaskerTest).
- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q),
опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета.
- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen).
- [x] **AutoLeave** (player) — выход по триггерам: низкое ХП, N игроков в радиусе
(друзья игнорируются опцией), задержка + предупреждение. Выход через
`mc.disconnect(null, false)`; чистая state-machine `LeavePolicy` (AutoLeaveTest).
- [x] **AutoAccept** (player) — авто-ответ на ТП/клан-запросы: паттерны фраз (SetValue),
команда ответа, «только друзья» (PlayerRelations), задержка, кулдаун ≥500мс.
Пакетный путь: PvpChatEvent (handleSystemChat/handlePlayerChat), отправка
`connection.sendCommand(...)`. Чистый парсер `TeleportRequestParser` (AutoAcceptTest).
- [x] **SoundReducer** (player, ~42) — РЕШЕНО: развитие NoSound, а не новый модуль.
Режимы Mute (старое поведение через ClientPacketListenerMixin) и Reduce —
новая громкость 1–100% через SoundEngineMixin (@Redirect getVolume в
SoundEngine.play). Чистая математика `NoSound.resolveScale` (NoSoundTest).
- [x] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя)
- [x] **ThirdPerson** (player) — свободный обзор от 3-го лица по удержанию бинда (L,
настраивается), движение игрока не поворачивается. MouseMixin.turnPlayer копит
orbit-yaw/pitch, CameraMixin.update (TAIL) ставит камеру за игрока по орбите
(+ cull-frustum). Орбиты сбрасываются, когда бинд отпущен.
- [x] **UseTracker** (player) — уведомления: мои тотемы (N осталось), тотемы других,
эффекты оппонентов (группа: invisibility/speed/strength/regeneration/fire_res/weakness).
Пакетный путь: ClientboundEntityEventPacket (event 35) + ClientboundUpdateMobEffectPacket.
Тест UseTrackerTest.
- [x] **NoServerPack** (misc) — авто-отклонение принудительного серверного ресурспака
(спрашивать/отклонять всегда). Точка: ClientCommonPacketListenerImplMixin.
- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations).
- [x] **TapeMouse** — ОСТАВЛЕН (решение владельца 2026-09-08): автоклик по таймеру
(Attack/Use) для AFK-ферм, интервал, опция «только при зажатии». Считаем
допустимым исключением из Фазы 3.5 по явному решению владельца.
### Кандидаты из отчёта (не в списке пользователя — обсудить)
- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно.
- [x] **ItemScroller** (misc) — shift-перекладка предметов колёсиком в контейнерах:
QUICK_MOVE клик по слоту под курсором, иначе слот хотбара (опция).
Точка: AbstractContainerScreenMixin.mouseScrolled; чистая логика
`ScrollMovePolicy` (ScrollMovePolicyTest).
- [x] **Arrows / off-screen индикаторы** — ОТМЕНЕНО (решение владельца 2026-09-08).
### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓
## Фаза 7.5: Команды — префикс % + автокомплит
- [x] Префикс команд сменён `@` → **`%`** (старый `@` принимается как legacy).
Источник истины — `features/command/CommandPrefix` (CHAR/STRING/isCommandLike/strip);
вызовы обновлены: CommandManager.handle/suggest, ScreenMixin (RunCommand),
BetterChat (TabHandler, SuggestionState, SuggestionRenderer, ContextMenuHandler),
usage-строки и RunCommand-ссылки всех команд (help/modules и др.).
- [x] Аргументный автокомплит дополнен usage-подсказками: если `suggest()` команды
не дал вариантов на позиции — показывается подсказка вида `%toggle <module>`
(`features/command/UsageHints`, парсит `<...>`/`[...]` из usage-строки).
Существующий UI автокомплита BetterChat (стрелки/Tab/клик) работает как раньше.
### Проверка: `./gradlew build` ✓, `./gradlew test` ✓ (CommandPrefixTest, UsageHintsTest,
### CommandDiscoveryTest обновлён)
## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН
> Правило ≤200 уже есть (Фаза 1–3), но долг: ~240 файлов >200 строк.
> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) /
> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов.
- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527,
DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181,
HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042
- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤200, 0 изменений вызывающих,
`./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу
- [ ] Facades >200 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам)
- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша
### Проверка: `./gradlew test` зелёный
## Фаза 8.5: Аудит структуры (правила ≤200 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ
> Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк).
> **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать,
> должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком.
> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤200, 0 правок вызывающих,
> билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже.
### Итоговая картина
- **Файлы >200 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140
- **Папки >4 файлов: 86 шт.**
- Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 |
features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное
### 8.5.1 Папки >4 файлов — топ должников (разбить на подпапки по смыслу)
- [x] `mixins/` — 108 файлов → leaf-пакеты (entity/blaze3d/input/world/network/gui/hud/render),
mixins.json перегенерирован, FQN-строки LoVisualMixinPlugin обновлены. Коммиты 94d5af7, 3c76817.
- [x] `mixins/accessors/` — 45 → 12 групп (entity/renderer/world/state/packet/inventory/...). Коммит 3c76817.
Также разбиты iris/sodium/security на подпакеты.
- [x] `features/module/modules/visuals/` — 67 → esp/nametags/hats/effects/weather/scene/camera/
player/items/fx + lyrics/lrc. Конфиг-id НЕ менялись (проверено). Коммит 073ac26.
- [x] `features/gui/clickgui/settings/` — 51 → core/bool/mode/slider/text/text-list/group/bind/
color(+render/preset/hex)/i18n/render/skin/owner/cooldown/util. Коммит 9db1fec.
- [x] `features/gui/hud/draggable/impl/` — 40 → stats/metrics/status/combat/panels/targethud(+/render,
render/parts)/scripted + hudnotifier/model, targethud-модель свёрнута в класс с record-ами. Коммит 6b89e40.
- [x] `events/impl/` — 32 → input/movement/world/network/entity/pvp/render/player;
events/ верхний уровень: core(Event,EventBus,Events) + meta(EventHandler,UsedImplicitly).
- [x] `features/gui/chat/` — 31 → `render/`, `suggestions/`, `menu/`, `model/`.
- [x] `render/engine/uniform/impl/` — 28 → по потребителям (`uniform/impl/pipeline/`, `uniform/impl/effects/`).
- [x] `render/engine/renderer/ui/` — 28: верхний уровень разнести по уже существующим
`draw/`, `runtime/`, добавить `blur/`, `batches/`.
- [x] `render/engine/text/` — 24 и `text/backend/` — 12 → `text/` разбить на `text/render/`,
`text/layout/`, `text/msdf/`.
- [x] `render/engine/renderer/ui/runtime/script/` — 23, `ui/draw/` — 23 → внутри по 4-файловым кластерам.
- [x] `features/gui/hud/nondraggable/impl/` — 20 (+`impl/tab/`) → `impl/overlays/`, `impl/tablist/`.
- [x] `features/gui/clickgui/` — 20 на верхнем уровне → всё в существующие `layout/`, `sections/`,
`picker/`, `protocol/`, `util/`.
- [x] `render/engine/command/` — 17, `render/helpers/` — 16, `features/command/impl/` — 16,
`config/values/` — 16 → по 2-3 подпапки (типам: `values/numeric/`, `values/color/`…).
- [x] `render/engine/profiler/` — 14, `modules/player/` — 14, `features/gui/hud/` — 14,
`addon/` — 14 — средние: раскидать при касании.
- [x] Хвост (5–13 файлов, ~55 папок): `util/pvp` 13, `util/projectile` 13, `render/iris` 13,
`config/profile` 13, `features/module/misc` 12, `features/module` 12, `config` 12 и т.д. —
ЗАКРЫТ выделенным проходом (коммиты 8846d53…303b17e, волна 8.5.1 завершена).
Снимок после волны: 0 папок >4 файлов в `src/main/java` (скан 2026-09-10).
Замечания: command-discovery рекурсивный (ClassGraph acceptPackages), конфиг-id
и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными,
расширены до public/protected; svgMsdfTool-импорты починены после split svg.
### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА
> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк
> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53
> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон,
> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤200».
> Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка:
> Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные
> классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`.
- [x] Renderer2D 2179→1597 → ГОТОВО 7b848ddd+464f7868+098c5515 (2026-09-25): ФАСАД;
далее 1597→475 наследованием (волна 2026-09-25 ниже)
(исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы
совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners,
−115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >200 нет),
хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓
- [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/`
(Screen 132, Controller 200, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100
+ render/cards/ 178/166), все ≤200, build+test ✓
- [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) +
render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) +
render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓
- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤200 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/`
- [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами
- [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести
- [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно
- [x] WorldParticles 991 → ГОТОВО 1d757a6+5a463d7e: 999→821→260 (фасад) + ambient/render/ 3×≤182
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
типы частиц в `visuals/effects/ambient/`
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
+ render/cards/ 79/88), все ≤200 — по образцу AltManager
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
- [x] UiMeshGeometry 954 → ГОТОВО sub-agent: 956→99 + 4×≤135 — по примитивам геометрии
- [x] BetterTooltips 953 → ГОТОВО 0b8ea41: 960→181 +4×≤199; OrderedUiBatcher 935 → ГОТОВО 6e0bf7f: 944→188 (→ batches + blur), Scoreboard 924,
Rain 893 → ГОТОВО sub-agent: 903→159 +4×≤162; CompactHudStatModel 893 → ГОТОВО 8979140: 894→125 +4×≤175; TazikHat 859 ✓ ✓ закрыт портированием в
Cosmetics built-in (2026-09-11, см. Фазу 7),
ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166,
GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) +
mixins/render/main/handlers/ 4×≤200 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост)
- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤200 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791,
HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739,
Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701,
Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673,
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
bars/ закрыт: все файлы ≤200, impl/ = 3 файла.
- [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше,
7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d
(SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены;
логика ≤26 строк, фасад-исключение), build+test ✓.
- [x] Волна 2026-09-25 (наследование фасада): Renderer2D 1597→475 (leaf), цепочка
Renderer2D extends Item(124) extends Glass(183) extends Shapes(313) extends
Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся
(0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset
работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит;
Base.self() для this→Renderer2D-делегатов; семейства >200 — фасад-части
(исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+
9fffc58d, build+test+checkstyle ✓.
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
- [x] Волна 2026-09-30 (топ-4 по объёму, выбор владельца): каждый файл — отдельный
коммит, фасад + хелперы, публичный API и вызывающие не тронуты:
ConfigDiffPanelComponent 393→167 (фасад + `config/diff/`
HeaderRenderer/BodyRenderer/Scrollbar) 7e8b6b64;
RotationManager 391→199 (фасад + `rotation/internal/`
RotationState/RotationStepper/RotationPackets, @EventHandler-методы остались
на фасаде — регистрация не менялась) 3e57542b;
ScriptedTriangulatorHudPanel 386→189 (записи Panel/Palette остались
вложенными → `ScriptedTriangulatorHudPanel.Panel` у вызывающих работает,
рендер → ScriptedTriangulatorRenderer, пропсы/сигнатуры → `scripted/panel/`)
76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу
BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов)
71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге;
файлов >200: 155 → 151
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
разбивка на ≤200, вторым — целевое изменение (иначе diff-каша, правило Фазы 8)
### 8.5.4 Порядок и критерии готовности
- [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) →
8.5.2 гиганты >800 → далее по убыванию. Каждая папка/файл = отдельный коммит.
→ 8.5.1 ЗАВЕРШЕНА (2026-09-10): 0 папок >4 файлов.
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
не трогают string-literals id — проверять grep'ом после moves.
- [x] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
244/86 — долгий хвост, 9.2 им не блокируется.
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >200:
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН
> Диагноз: у клиента есть «налог каждый кадр», который платится даже при
> выключенных модулях (друг: просадка FPS на чистом конфиге, Windows).
> Решение владельца: мёртвые грузы — ЧИНИМ в коде; авто-деградации по FPS НЕ ДЕЛАЕМ;
> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы).
### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет
- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass`
(`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors`
(`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на
submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с
Windows-драйвером, несколько мс/кадр. Фикс: проверять только если
`mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом;
в релизе — no-op.
- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean
(`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()`
заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean.
- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) —
`LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт
`needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth`
(`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`,
`LoVisualVisuals:79`, `WorldPassCompiler:174`.
- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource`
(`:116-123`): полноэкранный color-copy делается до проверки
`liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда
blur реально запрошен.
- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site
каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290`
(`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check.
- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался.
### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии
> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) —
> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >200 строк.
> Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS.
> Пока активен — движок и HUD работают в лёгком режиме.
- [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит
`checkFolderLimit`=4 файла в `misc/`), 4 bool-тумблера (default on), тест `OptimizeTest`.
Отклонения от плана при реализации:
- `no_glass` — **9246e010**: не мутим bgEffect (конфиг элементов неизменяем) и не
ищем `prepareSharedBlurInternal` (метода больше нет). Гейты: `Blur2D.source()`
→ null при no_glass (вместе с `FpsOptimizer.isGlassDisabled` → все
blurRect/glassBlurRect мимо) + ранние return в
`UiBlurResources.requestLiquidGlassBlur/requestBeforeNextShapeClip`
(флаги не ставятся → `captureWorldSource` early-out → нет полноэкранного copy,
kawase и prewarm). Побочный эффект: гасит и низкоконтрастный blur текста
(осознанно — «все glass/blur off» по замыслу тумблера).
- `lite_post` — **52463246**: MotionBlur `SHADER_TAPS=11 → LITE_TAPS=5`
(план «15→5» устарел — тапов уже 11), `historyValid = hasHistory && !lite`
(temporal-история off = ещё ~10 neighborhood-fetch'ей в шейдерный early-out;
цена: хвостов истории нет, остаётся чисто пространственный блюр).
DoF: `taps = lite ? LOW : quality` (LOW=8; план «32→8» устарел — квалити 8/14/22).
- `lite_sky` — **c403cb53**: `SkyboxShaderPasses.LITE_SKY_LAYER_MASK=0xF00F`
(базовые слои 0-3 + звёзды 12-15, чистит nebula/curtains/bursts/polar/water/…),
`layerMask &=` при включённом тумблере; в `reimagined_skybox.frag` 4
безусловных fbm + bursts-блок обёрнуты в layer-гейты; pulsar/thunder fbm
4→2 октавы через `u_Thunder.w` (ранее всегда 0). Тест `SkyboxShaderPassesTest`,
все 3 шейдера проходят `glslangValidator -S frag`.
- `lean_hud` — **8746074d**: `GradientTexts.horizontalFade` → plain-текст (без
per-glyph `new String` и gradient-лямбд; marquee-clipped — clip остаётся,
fade-градиенты гаснут) в Custom и Vanilla-реализациях; Fps digit-roll off
(`FpsAnimator.widerText/digitProgress` → settled-прогресс).
Не в scope: `GradientTexts.gradient/quadGradient` (визуал списка модулей),
аллокация в `HorizontalFade.glyphAdvance` (её просто обходит lean-гейт).
- [x] Механизм «как движок узнаёт» — ГОТОВО ade82f06: `render/engine/optimize/
OptimizeState.java` (enum-utility, 4 volatile boolean, `apply/reset`, чистый
статик без состояния модуля), движок читает геттеры, модуль пушит в onEnable/onFrame.
- [x] Взаимодействие с 9.1 — ГОТОВО: фиксы 9.1 не завязаны на Optimize и продолжают
работать всегда; Optimize только добавляет гейты сверху.
- [x] i18n en_us + ru_ru — ГОТОВО ade82f06: `module.optimize.*` + `setting.optimize.*`
(4 ключа, симметрия en/ru проверена).
- [x] Нотификация при первом включении — ГОТОВО ade82f06: once-флаг в инстансе модуля +
`Notifier.info("Optimize: " + summaryOf(...))` со списком включённых тумблеров.
### 9.3 Проверка
- [x] `./gradlew build` после каждого пункта; `./gradlew test` зелёный — выполнялось
после каждого из 5 коммитов 9.2 (итог: build+test зелёные на 8746074d).
- [x] Порядок: 9.1 (по одному пункту на коммит) → 9.2 — 9.1 закрыта (коммиты выше),
9.2 закрыта ade82f06..8746074d.
- A/B-мерка до/после 9.1 на чистом конфиге — УБРАНА из плана (решение владельца
2026-09-25): требует запуска Minecraft на Windows, задачей не является.
## Фаза 10: Заход по референсам из Downloads (2026-09-15)
> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения —
> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого.
- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`,
референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`.
Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица
камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up
вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех»
брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась
максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры,
перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все).
Коммит e7e0ff2.
- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).**
Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже
является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass,
drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас
уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер
`assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как
`PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный
`WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна.
Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ
самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя:
мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками
Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only,
учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер).
У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш
wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если
захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой
к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality-
пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее.
- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.**
Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` +
`wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным
рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере
глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция.
Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги:
1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины,
шумная/блочная нормаль -> дрожащий край лужи;
2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` —
умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение
волны вместо её наклона.
Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе),
рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент
SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено
в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль
`visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо
от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra
-> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed,
Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при
одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде).
Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в
нашем движке в этом заходе) — можно добавить отдельно, если понадобится.
- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).**
Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer`
используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом
(кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника
не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке
удара), реализована через уже существующий движок: обычные аддитивные квады через
MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без
захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble
в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/
impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер —
расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по
контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓.
- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.**
Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над
СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей
свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор
НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего
jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через
`OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**:
лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см.
`heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip.
Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags),
`HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/
gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems
(дефолты как у референсного пресета "HMI"), проброс ItemStack через
HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные.
### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен
## Фаза 11: Аудит attack-визуалов — sacura 1.21.4 / GodWeer-26.2 (2026-09-22)
> Референсы распакованы в `ref/clients/sacura-1.21.4` и `ref/clients/godweer-26.2`.
> Вывод по большинству эффектов: у нас уже полноценнее (больше режимов, единый render
> engine с пайплайнами вместо сырого GL). Ниже — конкретные пробелы, которые реально
> стоит доделать; всё остальное сравнённое (HitEffect, KillEffect, JumpCircles,
> Hitboxes, ItemPhysic, ViewModel offsets) уже на уровне или выше обоих референсов.
- [ ] **ViewModel — `Custom` swing-режим.** sacura (`SwingAnimationsModule`) имеет режим
"Кастомная" с explicit слайдерами угол/наклон. У нас 8 фиксированных пресетов
(Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust), но нет свободно настраиваемого.
Также посмотреть на sacura-режимы "Возвращение"/"Возвращение к себе"/"Круговая" —
возможно стоят портирования как доп. пресеты, если визуально отличаются от наших.
- [ ] **HitEffect — опциональный distortion/glow post-process слой.** godweer
(`HitEffectRenderer`) кроме частиц кладёт screen-space distortion+glow слайдеры.
У нас частицы (Bubbles/Wave/Impact) есть, post-process слоя над ними нет.
Паттерн `PostProcessPass` уже есть (см. `TotemFX`), можно переиспользовать —
но не блокирующая, это чистый апгрейд поверх уже более богатой системы.
- [ ] **JumpCircles — easing на появление/исчезновение.** sacura использует `EaseBackIn`
для пружинки при спавне/затухании круга. Проверить `render/engine/math` — если
easing-утилита уже есть, подключить к текущему fade вместо линейного.
- [ ] **KillEffect — through-walls поведение.** godweer (`PopEffect`) имеет явный toggle
"сквозь стены" для beam/взрыв эффектов. Проверить DepthMode у Lightning/Orthodox
beam в нашем KillEffect — сейчас режется окклюзией или нет; если режется и это не
специально — добавить toggle по аналогии.
- [ ] **Trails — живое сравнение с sacura `TrailsModule`.** Не разбирался по коду в этом
заходе (визуальный эффект, решение лучше принимать посмотрев обе стороны вживую
в игре, а не по исходникам).
### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта,
### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые,
### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle).
---
## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска
> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить).
> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`);
> всё, что ниже помечено «не проверено вживую», видно только в игре.
### Сделано (код + сборка + тесты зелёные)
- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели,
линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение.
Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест).
`TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в
`targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`.
- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест,
Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`,
круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`),
12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`.
- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен,
лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет,
количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего).
- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout`
(`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика
`hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы.
- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`,
`VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом
микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar.
- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR`
(rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом.
Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были,
6-й параметр в длинной форме это `softness`, а не alpha.
- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin`
подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `<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` падает при нарушении),
без длинного тире в текстах сайта и ответах.
# LoVisual Mod (26.2): план и статус
Мод вырос из форка Combatant Client. Полный старый текст (фазы 1-12, все решения, коммиты, списки файлов) сохранён
в `docs/TODO_HISTORY.md` (770 строк, не правим); здесь только актуальное.
## Правила (обязательные)
- KISS / DRY / SOLID, без оверинжиниринга; публичный API менять можно, мёртвый код удалять.
- ≤250 строк на файл (новый код держим ≤200), ≤4 .java файла на папку (`checkFolderLimit` падает при нарушении).
- Коммиты на английском; владелец просит большие коммиты, не дробить; ничего не пушить без просьбы.
- Без `static Minecraft` в полях, без this-escape, без мёртвых миксинов и файлов-пустышек.
- Каждый этап компилируется (`./gradlew build`), у нового кода тесты (JUnit) на чистую логику.
- Тексты сайта и ответы без длинного тире; ответы владельцу по-русски.
## Сделано (кратко)
- Ребрендинг, удаление Runtime/JarReplacement, разбивка файлов-гигантов (>600 строк), удаление читерских модулей, тесты.
- Перенос QoL-модулей из Delta (StreamerMode, LockSlot, AutoRespawn/Leave/Accept, ThirdPerson, UseTracker, NoServerPack,
DeathCoords, ItemScroller, TapeMouse, SoundReducer и др.), команды с префиксом `%` и автокомплит (в том числе в ванильном чате).
- Оптимизация FPS (мёртвые грузы, модуль Optimize), аудит attack-визуалов по sacura/GodWeer.
- Сессия 2026-10-01: TargetESP (Созвездие, Мечи), JumpCircles, KillEffect Blood (с depth), Hero/orbs главное меню и его
настройки, Simple Voice Chat (мягкая интеграция, модуль, HUD), тултипы (amber по умолчанию, выключаются, авто-масштаб под GUI
Scale), описания модулей и плашка описания в ClickGUI, `%tier` с подсказками ников, краши запуска (MixinExtras 0.5.5, helper-классы
вне `mixins.*`, порядок static init тем).
## Фаза 13: порт идей из evo (отчёт: `docs/EVO_REPORT.md`)
Лицензия: код и ассеты evo проприетарные (`ref/evo/README.md`). Для релиза только свои модели/звуки или разрешение автора;
пути из отчёта нужны для локальной отладки, ассеты evo в репозиторий не коммитить.
- [x] HoldMyItems Scripted на LuaJ: режим Dynamics/Scripts/Both; `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` +
аддоны (из `config/lovisual/holdmyitems/`, горячая перезагрузка); песочница без io/os/luajava; `V:cancelEquip/cancelSwing`,
`animator` (квады модели), `particleManager`, `C:setCamRot/setCamPos`, «всегда обе руки». Код: `held/lua/{runtime,api,ext}`.
Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/`using_item`, ItemStack-поля, roll камеры, ресурс-паки.
- [x] TNTTimer: уже был в `Predictions` (плашка с таймером); добавлены «Иконка TNT» и «Полоска фитиля».
- [x] ProjectileTrails: новый модуль `visuals/effects/projectiletrails/` (`TrailStore`, `TrailRibbon`): ленты за стрелами, трезубцами,
жемчугом, снежками, яйцами, зельями, огненными шарами, ветряными зарядами, фейерверками; длительность, ширина, заливка
Solid/Glow, контур, глубина, 8 режимов цвета. Шейдерные заливки evo (Nebula, Plasma, Matrix и т.д.) не портированы.
- [x] DeathEffects: режимы KillEffect Shatter, Ascend, Explosion, Disintegrate (`hits/death/`: `DeathPhysics` чистая + 6 тестов,
`DeathBody`, `DeathMesh`, `DeathEffectHelper`; тело из 6 частей с UV скина игрока, у мобов серые кубы). Настройки в
`DeathEffectSettings`. Не сделано: скрытие ванильного трупа на время эффекта, шейдер `death_blast`, режим Lightning из evo
(у нас уже есть свой Lightning).
- [x] GuiPresence (идея evo, наш бэкенд): `backend/chat-service` (новый крейт, in-memory, `POST /presence/gui`, привязка mc uuid к аккаунту,
TTL 6 с, план и протокол в `backend/chat-service/PLAN.md`), маршрут `/presence/*` в gateway (+ `CHAT_HTTP_URL`, лимит 6/с на аккаунт),
Dockerfile/compose/dev-stack. Мод: `platform/presence/{PresenceCodec,PresenceTimeline,PresenceClient}` (кодек 36 байт, плейбэк с
задержкой 300 мс, адаптивный опрос 350 мс/2 с), модуль `visuals/presence/GuiPresence` (делиться своим меню, показывать чужие,
дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
просмотр видео (`.watch` у evo), деплой chat-service на прод СДЕЛАН 2026-10-02 (rsync backend + `docker compose up -d --build chat-service gateway`; на сервер не уехали накопленные правки accounts-service/configs-service, они ждут отдельного деплоя).
- [x] ElytraTrails и Blizzard сверены с оригиналом evo (`ref/evo/.../ElytraTrailsModule`, `BlizzardModule`), спец-тесты по поведению evo:
ElytraTrails: концы крыльев по настоящей цепочке матриц модели элитры (`pose/WingTipLocator`, `ElytraPoses`), горизонтальная
лента, сужение Grow/Shrink, колебания Wave/Helix, плавный старт, реакция на скорость, мягкие края, ореол, свой/чужие, разрыв ленты
при скачке больше 8 блоков или паузе больше 300 мс, шаг записи 0.35, до 256 точек (`ribbon/RibbonShape`, `TrackBuffer.addContinuous`).
Не сделано из evo: заливка шейдером (8 видов), раздельные цвета крыльев.
Blizzard: снег только над землёй и под открытым небом (карта высот, спавн между землёй и потолком, нет снега в помещении),
константы ветра/плотности/турбулентности как у evo, экспозиция неба, «Whiteout» (миксин тумана `BlizzardFogMixin`), скрытие ванильной
погоды (`WeatherHideMixin`), рендер «капля» вдоль скорости, обмерзание 32 полосы, «только в снегопад» по силе осадков.
Не сделано из evo: «Snow cover» (снег накапливается на земле, шейдер). Не проверено вживую: вид, туман, положение концов крыльев.
- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
- [ ] Knife (Melee Skins): 33 скина, клипы draw/inspect/melee_1..3/run, first/third person, звуки, шейдер holy (отчёт раздел 3.7,
порядок работ из 8 шагов). Ресурсы: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/`.
- [x] Быстрые модули P1 (2026-10-02, сверены с `ref/evo/mod/jar-src-named/evovis/module/{misc,render}`, тесты зелёные, вживую не смотрели):
AutoSprint (`player/`, как у evo держит спринт; вход через `SprintControlEvent`, решает `SprintController.canStartSprinting`),
TotemCounter (`visuals/totems/`, свои тотемы справа от хотбара; счёт попов чужих у нас уже в `util/pvp/opponents/TotemPopSnapshot`,
без отдельной клавиши сброса как у evo), EnchantGlint (`visuals/items/glint/`: стили Rainbow/Solid/Gradient/Hidden, сила, скорость,
масштаб, «предметы/броня»; у evo свои RenderType и 11 стилей, у нас перерисовка ванильных текстур блеска `GlintTexture` +
`GlintTextureMixin`; нет Pulse/Fire/Galaxy/Hologram/Glitch/Sparkle/Aurora, Coverage), ArmorColor (`visuals/items/armor/`: цвет по
прочности Gradient/Steps, цели Players/Friends/Self/Mobs, Only Below, Low Pulse; миксин в `EquipmentLayerRendererMixin`, флаг цели
в `LivingEntityRenderState`), PlaceAnimation (`visuals/placeanimation/`: блок вылетает из руки, наклон/масштаб, как у evo: скрытие из
меша `SectionCompilerPlaceMixin` + Sodium `SodiumPlace*Mixin`, отрисовка движущимся блоком `LevelRendererPlaceMixin`, блок-сущности
`BlockEntityPlaceMixin`; формулы кривых и сторона руки восстановлены по идее evo, направление наклона и сдвига по вертикали подобраны
на глаз).
Проверить в игре: миксины брони/блеска/PlaceAnimation (при ошибке прислать лог), вид наклона и сдвига блока, перекраску блеска и её
возврат при выключении, Sodium (скрытый блок не оставляет дыр у соседей), TotemCounter при GUI Scale 2/3/4.
- [x] KineticLyrics починен (2026-10-02): `LyricPlayback.loadTrack` вызывал `reset()` после записи ключа трека, ключ стирался, загруженный
текст отбрасывался, а запрос уходил заново каждый тик (lrclib отвечал 503). Теперь `lrc/source/LyricsFinder`: lrclib (точный запрос с
длительностью, ранжированные поиски `lrc/match/LyricMatch`, обычный текст получает оценочные тайминги `PlainLyricTimer`), запасной
NetEase, повтор при занятом сервисе (до 6 раз). Нет из evo: YouTube Music и распознавание Whisper. Проверить вживую на Spotify.
- [x] CustomModels (2026-10-02, из `CustomModels-2026-10-02-120830.zip`): `visuals/models/` + `mesh/` (Chicken, Rabbit, Verity из .bbmodel),
ассеты в `assets/lovisual/models/`, миксины `render/models/`. Только у вас на экране, цели: себя/друзья/все. Нужна проверка вживую
(поза рук, рука от первого лица, Verity под Iris/Sodium). Исходный проект зарезан до этого модуля, остальные его части не нужны.
- [ ] GodRays (референс `shaders/core/godrays`).
- [ ] Дальше P1-P2 — перенос модулей из evo поштучно (референс: `docs/EVO_REPORT.md` §1.2;
лицензия evo проприетарная — их ассеты/код в репо не кладём, только идеи):
- [ ] **Blizzard** (render, evo ~1290 LOC, M/L) — высотная метель: ветровые снежинки с горизонтальным
сносом, белая пелена, обмерзание краёв экрана.
- [ ] **FoodInfo** (~889, M) — сытость/восстановление и подсказка оптимального порядка еды на полосе голода.
- [ ] **ElytraTrails** (~2208, M) — светящиеся шлейфы с концов крыльев элитры, переиспользовать наш `Trails`.
- [ ] **CustomHand** (~373, M) — пост-процесс шейдер руки (частично есть `shaders/hand_glass.frag`).
- [ ] **DynamicLights** (~553, M) — факелы/лампы в руке светят по-настоящему; сначала проверить наш `Light`.
- [ ] **TotemSound** (~257, S) — свой звук при срабатывании тотема.
- [ ] **Sounds** (~195, S) — звук на клиентские действия (открытие инвентаря, прыжок, клик в GUI…).
- [ ] **InventorySort** (~390, S/M) — сортировка инвентаря по пресету (клиентские QUICK_MOVE, без чужих пакетов).
- [ ] **StorageTracker** (~678, M) — память содержимого контейнеров, поиск предмета, подсветка где лежит.
- [ ] **ConsumeESP** (~671, M) — круг с кольцом прогресса над целью, когда она ест/пьёт.
- [ ] **PatPat** (~645, M) — гладить моба пустой рукой (бинд, анимация/частицы, всё локально).
- [ ] **Showcase** (~882, L) — кинематографичный облёт постройки камерой; запись клипа — позже (MVP: облёт).
- P3 (следующий заход): Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel,
Discord RPC, Replay, Trainer. Полный список и сложности: отчёт, раздел 1.2.
## Открытые задачи
### Проверить в игре (код готов, вживую не смотрели)
- Автокомплит `%` в ванильном чате (`%`, `%to`, Tab, `%tier `); если миксин не применится, прислать лог.
- Режимы TargetESP Созвездие и Мечи; формы JumpCircles; кровь (лужи, яркость, depth); новые режимы KillEffect (ориентация
граней частей, UV скина, яркость искр); ProjectileTrails (вид ленты, глубина); Hero/orbs-меню.
- HoldMyItems Scripted: знаки осей `M:rotate`, видимость Lua-частиц, анимация квадов на реальной модели.
- GuiPresence: нужен запущенный стек (`backend/scripts/dev-stack.sh`) и два клиента с привязанными аккаунтами на одном сервере.
- SpatialGui (инвентарь был прозрачным): сверено с `ref/mods/Spatial-GUI`; убрано ванильное затемнение в захвате (`ScreenSpatialBackgroundMixin`) и
композитинг плоскости переведён на premultiplied-alpha пайплайн `WORLD_TEXTURED_PREMULTIPLIED`. Проверить в игре; если всё ещё прозрачно,
следующий подозреваемый: глубина захвата (clear 1.0) и HUD внутри захвата (у эталона захватывается только экран, отдельным GuiRenderer).
- Войсчат с настоящим SVC 2.6.24 (события mute/speaking, значок шапки HUD).
- Размер плашки описания в ClickGUI и авто-масштаб тултипа при GUI Scale 2/3/4.
### Решения и долги
- Тип ClickGUI: селектор «Тип ClickGUI» (`classic` по умолчанию), точка выбора экрана `ClickGuiScreenManager`; ждёт решения,
какой второй вид делать.
- ViewModel: свободный `Custom` swing и режимы sacura (Возвращение, Круговая); HitEffect: distortion/glow post-process;
JumpCircles: easing на появление; KillEffect: toggle «сквозь стены»; Trails: сравнение с sacura вживую.
- Фаза 8.5.3 (история: раздел 8.5): около 150 файлов 250-400 строк и папки >4 файлов. Правило: касаешься файла по другой
задаче, первым коммитом разбей на ≤250 (фасад + package-private хелперы, API вызывающих не трогать), вторым целевое изменение.
Очередь самых больших: LoVisualRenderPipelines, DraggableHudElementRegistry, Predictions, DynamicIsland, Module,
HudNotifier, AltManager, Triangulator, TargetESP, UiPrimitiveRenderer. Фасады (Renderer2D и т.п.) остаются исключением.
При переносах пакетов не ломать id модулей и ключи настроек (совместимость `.lvcfg`), после move проверять grep'ом.
- Разнос `addon`/`events`/`mixininterface` по смыслу отложен (54/41/88 файлов-импортёров, чистый import-churn): только скриптовым
отдельным проходом.
- Фаза 9 (FPS) закрыта: мёртвые грузы починены в коде, ручной модуль Optimize есть; авто-деградацию по FPS решено не делать.
- Фаза 7 (Delta QoL): Wasted и Arrows отменены, TapeMouse оставлен по решению владельца; список в истории.
- `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`.
- Приёмка серверного плагина (Velocity + `%policy`), бэкенд chat-service и addons-registry (корневой TODO.md).
## Заметки для сессий
- Игру запускает владелец: `cd mod && ./gradlew runClient` (из корня репозитория `./gradlew` нет).
- Параллельные сборки блокируют друг друга: `flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`.
- Helper-классы для миксинов нельзя класть в пакет `dev.loki.lovisual.mixins.*` (Mixin `IllegalClassLoadError`):
им место в `mixinhandlers/`, `render/` или рядом с фичей.
- Мировые пайплайны (`WorldPipelines`) рисуют без отсечения задних граней и без записи глубины: порядок обхода вершин не важен.
- Параллельная сессия может коммитить в тот же репозиторий: перед `git commit --amend` смотреть `git log -3`.

View file

@ -5,6 +5,7 @@
"item_info_colorize": true,
"tooltip_font": "Iosevka",
"tooltip_text_size": 1.0,
"tooltip_auto_scale": true,
"top_enchant_ignore": [
"minecraft:binding_curse",
"minecraft:vanishing_curse",

770
mod/docs/TODO_HISTORY.md Normal file
View file

@ -0,0 +1,770 @@
# LoVisual Mod (combatant-client-26.2) — план переписи
Мод — форк Combatant Client, переписываем под LoVisual.
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
- **Макс. 250 строк на файл** (было >1500) — превышение = разбить на модули
- **Макс. 4 файла на папку** — больше = новая подпапка по смыслу
- **Публичный API можно менять свободно** (решение владельца 2026-09-05):
KISS важнее обратной совместимости; при рефакторинге разрешено менять
сигнатуры, удалять делегирование и мёртвый код, обновляя вызывающих
- **Коммиты — на английском** (решение 2026-09-23, действует с этого момента
вперёд; более старые коммиты на русском не переписываем)
- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле
- **Никаких this-escape** — конструктор не публикует `this`
- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel
- **Без пустышек** — файл без реализации не создаётся
- **Каждый этап компилируется** — `./gradlew build` перед переходом к следующему
- **Тесты обязательны** для новых модулей (JUnit)
---
## СТАТУС ПРОБЛЕМ (что вижу сейчас)
### 1. Ребрендинг не закончен (пункт 2)
- 288 файлов / **1464 упоминания** `combatant` в main-коде
- Классы: `CombatantRenderSystem`, `CombatantRenderEngineBootstrap`, `CombatantRenderPipelines`, `CombatantVulkanBackend`, `CombatantFrameGraph`, `CombatantTerrainVertex` и др.
- Заголовки файлов: «This file is part of the Combatant Client distribution» / копирайт изначального автора
### 2. Файлы-гиганты (пункт 3) — 12 файлов > 1500 строк
| Файл | Строк |
|------|-------|
| render/engine/renderer/Renderer2D.java | 3379 |
| features/gui/chat/BetterChatRenderer.java | 2870 |
| features/gui/clickgui/settings/UnifiedSettingRenderer.java | 2798 |
| features/module/modules/player/Scaffold.java | 2416 |
| features/module/modules/combat/AttributeSwap.java | 1922 |
| features/gui/clickgui/ClickGuiRenderer.java | 1900 |
| features/module/modules/visuals/NameTags.java | 1791 |
| features/module/modules/combat/ElytraTarget.java | 1711 |
| features/gui/hud/draggable/impl/TargetHud.java | 1687 |
| features/module/modules/visuals/ESP.java | 1520 |
| features/module/modules/combat/AutoCrystal.java | 1516 |
| features/gui/hud/nondraggable/impl/CustomBar.java | 1496 |
### 3. Runtime / JarReplacement (пункт 4)
- `runtime/` (ClientRuntime, RuntimeGate, ClientRuntimeState, SOFT_PANIC/JAR_REPLACEMENT_PANIC, shutdown/resume-участники)
- `runtime/loader/` (JarReplacementCoordinator, LoaderBridge, PanicOnlyJarReplacementCoordinator)
- **Моё предложение: выкинуть.** Живая замена jar в памяти — источник classloader-утечек, хрупкая, для клиента не нужна (обновление делает лаунчер). Заменить на простой Lifecycle.
### 4. Security (пункт 7)
- `features/security/BackdoorProtection` — на деле защита от SSRF/translate-инъекций (порт ExploitPreventer, MIT). Полезное, но `enum`-синглтон со статикой.
- `features/relations/StaffTracker` + `StaffHeuristicsMatcher` — детект «стафа» по эвристикам TAB. Сомнительно, решить оставить/выкинуть.
### 5. Тесты (пункт 6)
- 5 тестов на ~213k строк. Добавлять по мере переписи модулей.
---
## Фаза 1: Ребрендинг (пункт 2)
- [x] **Восстановлен `config/`-пакет** (55+3 файла) из `ref/zip/combatant-client-26.2 (1).zip` — он отсутствовал в форке, без него проект не собирался. Переименован `combatant.client.config` → `dev.loki.lovisual.config`. `compileJava` ✓
- [x] Заменить `combatant` → `lovisual` в main-коде (1464 вхождения, 288 файлов)
- [x] Переименовать классы `Combatant*` → `LoVisual*` (23 шт., включая `IrisCombatantFrameHooks` → `IrisLoVisualFrameHooks`)
- [x] Заголовки файлов → LoVisual, пересобрать копирайты (GPLv3 форка)
- [x] Проверить `fabric.mod.json` / `lovisual.mixins.json` на остатки
- [x] Проверить строки/сообщения/логи на «combatant»
- [x] JNI-имена в native: `Java_combatant_client_` → `Java_dev_loki_lovisual_` (34 шт.) + FindClass-пути
### Проверка: `rg -i combatant src` → 0 ✓, `./gradlew build` ✓ (1406 файлов изменено)
## Фаза 2: Выкинуть Runtime/JarReplacement (пункт 4)
> **Вместе с рантаймом удалить и модуль Panic** (`features/module/modules/misc/Panic.java`),
> интерфейс `RuntimeControlModule`, команду `RuntimeCommand` и все ветки на них
> в `ModuleManager`/`Module`. Panic — единственная реализация `RuntimeControlModule`,
> без него они становятся мёртвым кодом.
- [x] Удалить `runtime/` и `runtime/loader/` (14 файлов)
- [x] Удалить `Panic.java` + `RuntimeControlModule.java` + `RuntimeCommand.java`
- [x] Убрать ветки `instanceof RuntimeControlModule` и `tickRuntimeControllers()` из ModuleManager/Module
- [x] Убрать `RuntimeGate` из точек входа (LoVisual.java, модули, рендер) — заменён на `Lifecycle`
- [x] Заменить на простой Lifecycle-класс (`dev.loki.lovisual.Lifecycle`: INIT → ACTIVE → SHUTDOWN, гейты `canRunRender/Hud/ShaderBridge` + `RenderResourceReadiness`)
- [x] Panic-фичи удалены; `ModuleManager.disableAll()` оставлен как простая утилита
- [x] Мёртвые cleanup-методы удалены (`shutdownForRuntime`, `clearRuntimeReferences`, `VanillaChatRuntimeCleanup`, i18n-ключи panic/runtime)
### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0
## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА
> Стратегия: публичный фасад исключён из лимита ≤250 (делегирует ~все публичные методы),
> вся логика вынесена в package-private final-хелперы в том же пакете (плоско),
> ≤250 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/
> AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно.
- [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors,
Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures)
- [x] BetterChatRenderer (2870) → фасад 210 + 21 хелпер (LayoutEngine, Viewport, FrameRenderer,
MessageRenderer, GlyphRenderer, InputRenderer, Suggestion*, InputHandler, MouseClick,
ContextMenu, Tooltip, Draw/Font/EditBox/Segment/CommandMerge + 3 model-файла)
- [x] UnifiedSettingRenderer (2798) → фасад 208 + 24 хелпера (по типам настроек + цвет-редактор
из 9 классов: Renderer, SquareRenderer, PresetRenderer/Handler, HexInput/State, Drag, Layout, Math)
- [x] ClickGuiRenderer (1887) → фасад 542 + 15 хелперов (Input, Keyboard, RenderEngine, Lifecycle,
Screen, TextBatch, DrawHelpers, KeyBind, Editor, PickerIcon, TopLayer, TabBar, TabState + Model + Fonts)
- [x] NameTags (1791) → фасад 438 + 11 хелперов (Renderer, Plate, LayoutEngine, Face, Equipment,
Opponent, Effects, LabelBuilder, EnchantFormatter, Cache + Model)
- [x] TargetHud (1655) → фасад 340 + 11 хелперов (Renderer, TargetResolver, Panel, Face, Content,
Text, Arc, Equipment, Effects, Formatter + Model)
- [x] ESP (1538) → фасад 269 + 11 хелперов (EntityCollector, ShaderRenderer/Mask/Color, 2D/3D,
SearchOverlay, Outline, MeshBuilder, ColorUtil + Model)
- [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model)
### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤250, фасады исключены;
### 0 вызывающих файлов изменено во всей Фазе 3
### Долг (файлы >250 вне списка гигантов, в т.ч. новые фасады):
### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449,
### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116,
### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042,
### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441,
### а также многие HUD/module/rendering-файлы 250–800 строк. Разбирать по желанию.
## Фаза 3.5: Удаление читерских модулей (легит-направление)
> Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются.
- [x] combat: удалены AttributeSwap, AutoAnchor, AutoAttack, AutoBed, AutoBow, AutoCrystal, Backtrack,
Criticals, ElytraTarget, Hitbox, KillAura, KTLeave, MaceKill. **Оставлены:** ProjectilePuncher,
PvpCooldowns, Reach, RubberHand, SpearAssist, TPSSync
- [x] movement: удалены ВСЕ модули (AirJump, AutoDodge, ElytraFly, ElytraHelper, Flight, Freeze,
InventoryMove, NoFall, NoPush, NoStun, Parkour, Phase, ReverseStep, SafeWalk, Speed, Sprint,
Step, Strafe, SuperFirework, TargetStrafe, Timer, Velocity, WindJump) — 22 файла
- [x] player: удалены AirPlace, AutoArmor, AutoEat, AutoTotem, Scaffold, XCarry.
**Оставлены:** AutoTool, ClickPearl, FakeLag, NoDelay, NoInteract, ShitDropper
- [x] Осиротевшие хелпер-пакеты: удалены `combat/autocrystal/` (17), `combat/autobed/` (9),
`combat/autoanchor/` (9), `util/block/scaffold/` (10), `BlockPlacer` + тест ScaffoldPlacementGateTest
- [x] Enum-заглушки: `KillAura.RaycastMode/RotationTiming/AngleSmoothMode` → локальные enum в
ProjectilePuncher/Reach; `Criticals.SelectionMode` → удалено; `Hitbox`-зависимость убрана из ESP
- [x] ESP переписан под легит: новые настройки `only_visible` (LOS-луч от камеры), `skip_invisible`,
`only_armor` (без брони не подсвечивать) — по умолчанию `true`, только для игроков
### Проверка: `./gradlew build` ✓, тесты ✓ (13), остатки только в комментариях/строках
## Фаза 4: Security (пункт 7)
- [x] BackdoorProtection: убрать `enum`-синглтон, сделать инстансный сервис, ленивый `Minecraft.getInstance()`
- [x] Решить судьбу StaffTracker/StaffHeuristics — согласовать и выкинуть или переписать
(решение: StaffHeuristics удалён, StaffTracker+StaffData оставлены, переведены на INSTANCE-паттерн)
- [x] Покрыть логику фильтров тестами (BackdoorProtectionTest, 11 тестов)
## Фаза 5: Модули — нормализация под правила
- [x] Прочесать все 131 модуль: убрать `static final Minecraft` в полях (6 шт. уже найдено)
(LegacyHudNotifier, ScissorFunction, ShitDropper, ProtocolUtil, CooldownRender, ClickGuiRenderer.MC
+ 7 файлов-потребителей ClickGui-пакета — всё переведено на ленивый `Minecraft.getInstance()`)
- [x] Проверить модули на `System.currentTimeMillis()` → заменить на игровое время
(16 файлов модулей → новый `util/time/GameClock.millis()`, откат на стенное время вне мира)
- [ ] Разнести addon/events/mixininterface по смыслу — ОТЛОЖЕНО: blast-radius 54/41/88 файлов-импортёров,
чистый import-churn без функциональной ценности; делать только отдельным скриптовым проходом
## Фаза 6: Тесты (пункт 6)
- [x] Юнит-тесты: EventBus (12), конфиг (values: NumberValue/Boolean/String/Set/ItemIdSet/BooleanMap,
ModeValue, EnumValue, RGB/RGBA color; profile: DiffEngine, ValueCodec),
утилиты (LegacyTextUtil — 12). Игровые классы без моков: тесты только на чистую логику.
- [x] По мере переписи модулей — тест на каждый (Текущий итог: 292 теста, все зелёные)
## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем
> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи,
> переписываем под наши правила; у Delta 114 модулей, у нас 63).
> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog,
> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага,
> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src.
### Решения по обсуждению (2026-09-05)
- [x] Wasted — НЕ делаем
- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт)
- [x] DeathCoords — делаем
- [x] ItemScroller / Arrows — НЕ делаем
- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты)
- [x] Правило папок: максимум 4 файла (было 3)
### Перенос (список пользователя)
- [x] **StreamerMode** (misc) — маскирует ники-подобные токены и названия серверов в чате
(общая точка NameProtect: ChatComponentMixin.addMessage), скины заменяются на
стандартные (AbstractClientPlayerMixin.getSkin). Чистая логика — `util/text/NameMasker`
(тест NameMaskerTest).
- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q),
опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета.
- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen).
- [x] **AutoLeave** (player) — выход по триггерам: низкое ХП, N игроков в радиусе
(друзья игнорируются опцией), задержка + предупреждение. Выход через
`mc.disconnect(null, false)`; чистая state-machine `LeavePolicy` (AutoLeaveTest).
- [x] **AutoAccept** (player) — авто-ответ на ТП/клан-запросы: паттерны фраз (SetValue),
команда ответа, «только друзья» (PlayerRelations), задержка, кулдаун ≥500мс.
Пакетный путь: PvpChatEvent (handleSystemChat/handlePlayerChat), отправка
`connection.sendCommand(...)`. Чистый парсер `TeleportRequestParser` (AutoAcceptTest).
- [x] **SoundReducer** (player, ~42) — РЕШЕНО: развитие NoSound, а не новый модуль.
Режимы Mute (старое поведение через ClientPacketListenerMixin) и Reduce —
новая громкость 1–100% через SoundEngineMixin (@Redirect getVolume в
SoundEngine.play). Чистая математика `NoSound.resolveScale` (NoSoundTest).
- [x] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя)
- [x] **ThirdPerson** (player) — свободный обзор от 3-го лица по удержанию бинда (L,
настраивается), движение игрока не поворачивается. MouseMixin.turnPlayer копит
orbit-yaw/pitch, CameraMixin.update (TAIL) ставит камеру за игрока по орбите
(+ cull-frustum). Орбиты сбрасываются, когда бинд отпущен.
- [x] **UseTracker** (player) — уведомления: мои тотемы (N осталось), тотемы других,
эффекты оппонентов (группа: invisibility/speed/strength/regeneration/fire_res/weakness).
Пакетный путь: ClientboundEntityEventPacket (event 35) + ClientboundUpdateMobEffectPacket.
Тест UseTrackerTest.
- [x] **NoServerPack** (misc) — авто-отклонение принудительного серверного ресурспака
(спрашивать/отклонять всегда). Точка: ClientCommonPacketListenerImplMixin.
- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations).
- [x] **TapeMouse** — ОСТАВЛЕН (решение владельца 2026-09-08): автоклик по таймеру
(Attack/Use) для AFK-ферм, интервал, опция «только при зажатии». Считаем
допустимым исключением из Фазы 3.5 по явному решению владельца.
### Кандидаты из отчёта (не в списке пользователя — обсудить)
- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно.
- [x] **ItemScroller** (misc) — shift-перекладка предметов колёсиком в контейнерах:
QUICK_MOVE клик по слоту под курсором, иначе слот хотбара (опция).
Точка: AbstractContainerScreenMixin.mouseScrolled; чистая логика
`ScrollMovePolicy` (ScrollMovePolicyTest).
- [x] **Arrows / off-screen индикаторы** — ОТМЕНЕНО (решение владельца 2026-09-08).
### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓
## Фаза 7.5: Команды — префикс % + автокомплит
- [x] Префикс команд сменён `@` → **`%`** (старый `@` принимается как legacy).
Источник истины — `features/command/CommandPrefix` (CHAR/STRING/isCommandLike/strip);
вызовы обновлены: CommandManager.handle/suggest, ScreenMixin (RunCommand),
BetterChat (TabHandler, SuggestionState, SuggestionRenderer, ContextMenuHandler),
usage-строки и RunCommand-ссылки всех команд (help/modules и др.).
- [x] Аргументный автокомплит дополнен usage-подсказками: если `suggest()` команды
не дал вариантов на позиции — показывается подсказка вида `%toggle <module>`
(`features/command/UsageHints`, парсит `<...>`/`[...]` из usage-строки).
Существующий UI автокомплита BetterChat (стрелки/Tab/клик) работает как раньше.
### Проверка: `./gradlew build` ✓, `./gradlew test` ✓ (CommandPrefixTest, UsageHintsTest,
### CommandDiscoveryTest обновлён)
## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН
> Правило ≤250 уже есть (Фаза 1–3), но долг: ~280 файлов >250 строк.
> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) /
> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов.
- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527,
DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181,
HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042
- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤250, 0 изменений вызывающих,
`./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу
- [ ] Facades >250 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам)
- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша
### Проверка: `./gradlew test` зелёный
## Фаза 8.5: Аудит структуры (правила ≤250 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ
> Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк).
> **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать,
> должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком.
> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤250, 0 правок вызывающих,
> билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже.
### Итоговая картина
- **Файлы >250 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140
- **Папки >4 файлов: 86 шт.**
- Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 |
features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное
### 8.5.1 Папки >4 файлов — топ должников (разбить на подпапки по смыслу)
- [x] `mixins/` — 108 файлов → leaf-пакеты (entity/blaze3d/input/world/network/gui/hud/render),
mixins.json перегенерирован, FQN-строки LoVisualMixinPlugin обновлены. Коммиты 94d5af7, 3c76817.
- [x] `mixins/accessors/` — 45 → 12 групп (entity/renderer/world/state/packet/inventory/...). Коммит 3c76817.
Также разбиты iris/sodium/security на подпакеты.
- [x] `features/module/modules/visuals/` — 67 → esp/nametags/hats/effects/weather/scene/camera/
player/items/fx + lyrics/lrc. Конфиг-id НЕ менялись (проверено). Коммит 073ac26.
- [x] `features/gui/clickgui/settings/` — 51 → core/bool/mode/slider/text/text-list/group/bind/
color(+render/preset/hex)/i18n/render/skin/owner/cooldown/util. Коммит 9db1fec.
- [x] `features/gui/hud/draggable/impl/` — 40 → stats/metrics/status/combat/panels/targethud(+/render,
render/parts)/scripted + hudnotifier/model, targethud-модель свёрнута в класс с record-ами. Коммит 6b89e40.
- [x] `events/impl/` — 32 → input/movement/world/network/entity/pvp/render/player;
events/ верхний уровень: core(Event,EventBus,Events) + meta(EventHandler,UsedImplicitly).
- [x] `features/gui/chat/` — 31 → `render/`, `suggestions/`, `menu/`, `model/`.
- [x] `render/engine/uniform/impl/` — 28 → по потребителям (`uniform/impl/pipeline/`, `uniform/impl/effects/`).
- [x] `render/engine/renderer/ui/` — 28: верхний уровень разнести по уже существующим
`draw/`, `runtime/`, добавить `blur/`, `batches/`.
- [x] `render/engine/text/` — 24 и `text/backend/` — 12 → `text/` разбить на `text/render/`,
`text/layout/`, `text/msdf/`.
- [x] `render/engine/renderer/ui/runtime/script/` — 23, `ui/draw/` — 23 → внутри по 4-файловым кластерам.
- [x] `features/gui/hud/nondraggable/impl/` — 20 (+`impl/tab/`) → `impl/overlays/`, `impl/tablist/`.
- [x] `features/gui/clickgui/` — 20 на верхнем уровне → всё в существующие `layout/`, `sections/`,
`picker/`, `protocol/`, `util/`.
- [x] `render/engine/command/` — 17, `render/helpers/` — 16, `features/command/impl/` — 16,
`config/values/` — 16 → по 2-3 подпапки (типам: `values/numeric/`, `values/color/`…).
- [x] `render/engine/profiler/` — 14, `modules/player/` — 14, `features/gui/hud/` — 14,
`addon/` — 14 — средние: раскидать при касании.
- [x] Хвост (5–13 файлов, ~55 папок): `util/pvp` 13, `util/projectile` 13, `render/iris` 13,
`config/profile` 13, `features/module/misc` 12, `features/module` 12, `config` 12 и т.д. —
ЗАКРЫТ выделенным проходом (коммиты 8846d53…303b17e, волна 8.5.1 завершена).
Снимок после волны: 0 папок >4 файлов в `src/main/java` (скан 2026-09-10).
Замечания: command-discovery рекурсивный (ClassGraph acceptPackages), конфиг-id
и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными,
расширены до public/protected; svgMsdfTool-импорты починены после split svg.
### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА
> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк
> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53
> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон,
> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤250».
> Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка:
> Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные
> классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`.
- [x] Renderer2D 2179→1597 → ГОТОВО 7b848ddd+464f7868+098c5515 (2026-09-25): ФАСАД;
далее 1597→475 наследованием (волна 2026-09-25 ниже)
(исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы
совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners,
−115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >250 нет),
хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓
- [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/`
(Screen 132, Controller 250, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100
+ render/cards/ 178/166), все ≤250, build+test ✓
- [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) +
render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) +
render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓
- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤250 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/`
- [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами
- [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести
- [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно
- [x] WorldParticles 991 → ГОТОВО 1d757a6+5a463d7e: 999→821→260 (фасад) + ambient/render/ 3×≤182
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
типы частиц в `visuals/effects/ambient/`
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
+ render/cards/ 79/88), все ≤250 — по образцу AltManager
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
- [x] UiMeshGeometry 954 → ГОТОВО sub-agent: 956→99 + 4×≤135 — по примитивам геометрии
- [x] BetterTooltips 953 → ГОТОВО 0b8ea41: 960→181 +4×≤199; OrderedUiBatcher 935 → ГОТОВО 6e0bf7f: 944→188 (→ batches + blur), Scoreboard 924,
Rain 893 → ГОТОВО sub-agent: 903→159 +4×≤162; CompactHudStatModel 893 → ГОТОВО 8979140: 894→125 +4×≤175; TazikHat 859 ✓ ✓ закрыт портированием в
Cosmetics built-in (2026-09-11, см. Фазу 7),
ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166,
GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) +
mixins/render/main/handlers/ 4×≤250 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост)
- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤250 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791,
HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739,
Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701,
Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673,
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
bars/ закрыт: все файлы ≤250, impl/ = 3 файла.
- [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше,
7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d
(SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены;
логика ≤26 строк, фасад-исключение), build+test ✓.
- [x] Волна 2026-09-25 (наследование фасада): Renderer2D 1597→475 (leaf), цепочка
Renderer2D extends Item(124) extends Glass(183) extends Shapes(313) extends
Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся
(0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset
работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит;
Base.self() для this→Renderer2D-делегатов; семейства >250 — фасад-части
(исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+
9fffc58d, build+test+checkstyle ✓.
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
- [x] Волна 2026-09-30 (топ-4 по объёму, выбор владельца): каждый файл — отдельный
коммит, фасад + хелперы, публичный API и вызывающие не тронуты:
ConfigDiffPanelComponent 393→167 (фасад + `config/diff/`
HeaderRenderer/BodyRenderer/Scrollbar) 7e8b6b64;
RotationManager 391→199 (фасад + `rotation/internal/`
RotationState/RotationStepper/RotationPackets, @EventHandler-методы остались
на фасаде — регистрация не менялась) 3e57542b;
ScriptedTriangulatorHudPanel 386→189 (записи Panel/Palette остались
вложенными → `ScriptedTriangulatorHudPanel.Panel` у вызывающих работает,
рендер → ScriptedTriangulatorRenderer, пропсы/сигнатуры → `scripted/panel/`)
76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу
BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов)
71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге;
файлов >250: 155 → 151
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
разбивка на ≤250, вторым — целевое изменение (иначе diff-каша, правило Фазы 8)
### 8.5.4 Порядок и критерии готовности
- [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) →
8.5.2 гиганты >800 → далее по убыванию. Каждая папка/файл = отдельный коммит.
→ 8.5.1 ЗАВЕРШЕНА (2026-09-10): 0 папок >4 файлов.
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
не трогают string-literals id — проверять grep'ом после moves.
- [x] Критерий закрытия для старта 9.2: ноль файлов >250 в путях, которые трогает Optimize
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
244/86 — долгий хвост, 9.2 им не блокируется.
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >250:
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН
> Диагноз: у клиента есть «налог каждый кадр», который платится даже при
> выключенных модулях (друг: просадка FPS на чистом конфиге, Windows).
> Решение владельца: мёртвые грузы — ЧИНИМ в коде; авто-деградации по FPS НЕ ДЕЛАЕМ;
> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы).
### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет
- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass`
(`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors`
(`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на
submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с
Windows-драйвером, несколько мс/кадр. Фикс: проверять только если
`mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом;
в релизе — no-op.
- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean
(`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()`
заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean.
- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) —
`LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт
`needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth`
(`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`,
`LoVisualVisuals:79`, `WorldPassCompiler:174`.
- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource`
(`:116-123`): полноэкранный color-copy делается до проверки
`liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда
blur реально запрошен.
- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site
каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290`
(`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check.
- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался.
### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии
> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) —
> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >250 строк.
> Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS.
> Пока активен — движок и HUD работают в лёгком режиме.
- [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит
`checkFolderLimit`=4 файла в `misc/`), 4 bool-тумблера (default on), тест `OptimizeTest`.
Отклонения от плана при реализации:
- `no_glass` — **9246e010**: не мутим bgEffect (конфиг элементов неизменяем) и не
ищем `prepareSharedBlurInternal` (метода больше нет). Гейты: `Blur2D.source()`
→ null при no_glass (вместе с `FpsOptimizer.isGlassDisabled` → все
blurRect/glassBlurRect мимо) + ранние return в
`UiBlurResources.requestLiquidGlassBlur/requestBeforeNextShapeClip`
(флаги не ставятся → `captureWorldSource` early-out → нет полноэкранного copy,
kawase и prewarm). Побочный эффект: гасит и низкоконтрастный blur текста
(осознанно — «все glass/blur off» по замыслу тумблера).
- `lite_post` — **52463246**: MotionBlur `SHADER_TAPS=11 → LITE_TAPS=5`
(план «15→5» устарел — тапов уже 11), `historyValid = hasHistory && !lite`
(temporal-история off = ещё ~10 neighborhood-fetch'ей в шейдерный early-out;
цена: хвостов истории нет, остаётся чисто пространственный блюр).
DoF: `taps = lite ? LOW : quality` (LOW=8; план «32→8» устарел — квалити 8/14/22).
- `lite_sky` — **c403cb53**: `SkyboxShaderPasses.LITE_SKY_LAYER_MASK=0xF00F`
(базовые слои 0-3 + звёзды 12-15, чистит nebula/curtains/bursts/polar/water/…),
`layerMask &=` при включённом тумблере; в `reimagined_skybox.frag` 4
безусловных fbm + bursts-блок обёрнуты в layer-гейты; pulsar/thunder fbm
4→2 октавы через `u_Thunder.w` (ранее всегда 0). Тест `SkyboxShaderPassesTest`,
все 3 шейдера проходят `glslangValidator -S frag`.
- `lean_hud` — **8746074d**: `GradientTexts.horizontalFade` → plain-текст (без
per-glyph `new String` и gradient-лямбд; marquee-clipped — clip остаётся,
fade-градиенты гаснут) в Custom и Vanilla-реализациях; Fps digit-roll off
(`FpsAnimator.widerText/digitProgress` → settled-прогресс).
Не в scope: `GradientTexts.gradient/quadGradient` (визуал списка модулей),
аллокация в `HorizontalFade.glyphAdvance` (её просто обходит lean-гейт).
- [x] Механизм «как движок узнаёт» — ГОТОВО ade82f06: `render/engine/optimize/
OptimizeState.java` (enum-utility, 4 volatile boolean, `apply/reset`, чистый
статик без состояния модуля), движок читает геттеры, модуль пушит в onEnable/onFrame.
- [x] Взаимодействие с 9.1 — ГОТОВО: фиксы 9.1 не завязаны на Optimize и продолжают
работать всегда; Optimize только добавляет гейты сверху.
- [x] i18n en_us + ru_ru — ГОТОВО ade82f06: `module.optimize.*` + `setting.optimize.*`
(4 ключа, симметрия en/ru проверена).
- [x] Нотификация при первом включении — ГОТОВО ade82f06: once-флаг в инстансе модуля +
`Notifier.info("Optimize: " + summaryOf(...))` со списком включённых тумблеров.
### 9.3 Проверка
- [x] `./gradlew build` после каждого пункта; `./gradlew test` зелёный — выполнялось
после каждого из 5 коммитов 9.2 (итог: build+test зелёные на 8746074d).
- [x] Порядок: 9.1 (по одному пункту на коммит) → 9.2 — 9.1 закрыта (коммиты выше),
9.2 закрыта ade82f06..8746074d.
- A/B-мерка до/после 9.1 на чистом конфиге — УБРАНА из плана (решение владельца
2026-09-25): требует запуска Minecraft на Windows, задачей не является.
## Фаза 10: Заход по референсам из Downloads (2026-09-15)
> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения —
> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого.
- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`,
референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`.
Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица
камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up
вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех»
брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась
максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры,
перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все).
Коммит e7e0ff2.
- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).**
Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже
является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass,
drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас
уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер
`assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как
`PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный
`WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна.
Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ
самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя:
мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками
Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only,
учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер).
У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш
wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если
захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой
к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality-
пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее.
- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.**
Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` +
`wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным
рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере
глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция.
Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги:
1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины,
шумная/блочная нормаль -> дрожащий край лужи;
2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` —
умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение
волны вместо её наклона.
Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе),
рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент
SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено
в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль
`visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо
от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra
-> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed,
Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при
одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде).
Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в
нашем движке в этом заходе) — можно добавить отдельно, если понадобится.
- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).**
Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer`
используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом
(кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника
не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке
удара), реализована через уже существующий движок: обычные аддитивные квады через
MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без
захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble
в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/
impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер —
расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по
контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓.
- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.**
Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над
СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей
свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор
НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего
jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через
`OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**:
лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см.
`heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip.
Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags),
`HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/
gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems
(дефолты как у референсного пресета "HMI"), проброс ItemStack через
HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные.
### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен
## Фаза 11: Аудит attack-визуалов — sacura 1.21.4 / GodWeer-26.2 (2026-09-22)
> Референсы распакованы в `ref/clients/sacura-1.21.4` и `ref/clients/godweer-26.2`.
> Вывод по большинству эффектов: у нас уже полноценнее (больше режимов, единый render
> engine с пайплайнами вместо сырого GL). Ниже — конкретные пробелы, которые реально
> стоит доделать; всё остальное сравнённое (HitEffect, KillEffect, JumpCircles,
> Hitboxes, ItemPhysic, ViewModel offsets) уже на уровне или выше обоих референсов.
- [ ] **ViewModel — `Custom` swing-режим.** sacura (`SwingAnimationsModule`) имеет режим
"Кастомная" с explicit слайдерами угол/наклон. У нас 8 фиксированных пресетов
(Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust), но нет свободно настраиваемого.
Также посмотреть на sacura-режимы "Возвращение"/"Возвращение к себе"/"Круговая" —
возможно стоят портирования как доп. пресеты, если визуально отличаются от наших.
- [ ] **HitEffect — опциональный distortion/glow post-process слой.** godweer
(`HitEffectRenderer`) кроме частиц кладёт screen-space distortion+glow слайдеры.
У нас частицы (Bubbles/Wave/Impact) есть, post-process слоя над ними нет.
Паттерн `PostProcessPass` уже есть (см. `TotemFX`), можно переиспользовать —
но не блокирующая, это чистый апгрейд поверх уже более богатой системы.
- [ ] **JumpCircles — easing на появление/исчезновение.** sacura использует `EaseBackIn`
для пружинки при спавне/затухании круга. Проверить `render/engine/math` — если
easing-утилита уже есть, подключить к текущему fade вместо линейного.
- [ ] **KillEffect — through-walls поведение.** godweer (`PopEffect`) имеет явный toggle
"сквозь стены" для beam/взрыв эффектов. Проверить DepthMode у Lightning/Orthodox
beam в нашем KillEffect — сейчас режется окклюзией или нет; если режется и это не
специально — добавить toggle по аналогии.
- [ ] **Trails — живое сравнение с sacura `TrailsModule`.** Не разбирался по коду в этом
заходе (визуальный эффект, решение лучше принимать посмотрев обе стороны вживую
в игре, а не по исходникам).
### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта,
### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые,
### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle).
---
## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска
> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить).
> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`);
> всё, что ниже помечено «не проверено вживую», видно только в игре.
### Сделано (код + сборка + тесты зелёные)
- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели,
линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение.
Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест).
`TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в
`targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`.
- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест,
Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`,
круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`),
12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`.
- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен,
лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет,
количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего).
- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout`
(`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика
`hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы.
- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`,
`VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом
микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar.
- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR`
(rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом.
Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были,
6-й параметр в длинной форме это `softness`, а не alpha.
- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin`
подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `<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 закрыт в пункте 1b ниже.)
- [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны
`hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`,
выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем
кадре); настройка «Всегда рисовать обе руки» (`always_hands`); `animator` (`ext/ModelAnimator` + миксин
`ItemModelAnimatorMixin` подменяет список квадов слоя, операции rotate/move/scale по диапазону индексов, pivot
в пикселях); `particleManager:addParticle{...}` с Lua-колбэком `tick` (`ext/HandParticles`, аддитивный BLOOM в
мире); `C:setCamRot/setCamPos` (`CameraScriptMixin`, действует 150 мс после вызова). Встроенные примеры для всех
скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/
`using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков.
Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели.
- [ ] 2. Wings: режим «Модель» из evo (подробности: `docs/EVO_REPORT.md`, раздел 2, шаги в 2.4).
Формат Bedrock `geo.json` + `animation.json`; 14 видов: angel, blue_dragon, dark, demon, dragon, easter, ghoul,
pulse, rocker, spider, trident, vulcano, wota (+ baby_dragon). angel/demon/dragon анимируются процедурно в коде
evo (`Internal29/41/42`), остальные из animation.json; wota с анимированной текстурой (64x640, 10 кадров по 150 мс).
Источник (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/*.{geo,animation}.json`,
`.../textures/cosmetic/wings/*.png`. Целевые пути: `assets/lovisual/wings/`, `assets/lovisual/textures/wings/`.
- [ ] `BedrockGeoParser` (общий с ножами) + запекание кубов в позиции/UV
- [ ] `BedrockAnimation` (keyframe, loop) и `ProceduralWingAnims` (angel/demon/dragon)
- [ ] `WingsRenderer.renderModel` (WORLD_TEXTURED[_DEPTH], якорь как у `drawWingSide`, подобрать знак Z и ориентацию)
- [ ] настройки в `Wings`: `wingSource` (Procedural/Model) + `wingModel` (14 ключей), tint/glow, lang en/ru
- [ ] тесты парсера, знаков pivot/rotation, UV, интерполяции
- [ ] 3. Модуль Knife (Melee Skins), раздел 3 отчёта, таблица классов в 3.7. 33 скина (21 геометрия, 12 перекрасок),
клипы draw/draw_1/inspect/inspect_1/melee_1..3/static_idle/run_start/run/run_end; ЛКМ чередует melee_1/2,
ПКМ melee_3, бег при спринте; first person и third person; звуки (94 ogg); шейдер holy.
Источник: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (index.json, *.geo.json, *.anim.json), `textures/cosmetic/melee/`,
`sounds/melee/`, `shaders/core/melee_holy.*`. Целевые: `assets/lovisual/knife/`, `textures/knife/`, `sounds/knife/`.
Порядок: (1) общий BedrockGeoParser, (2) клипы + `KnifeAnimationState`, (3) first person без рук, (4) руки игрока,
(5) third person (миксин `PlayerItemInHandLayer`), (6) звуки, (7) бег/варианты/holy, (8) UI выбора скина и сохранение.
Миксины: ввод (атака/ПКМ), `ItemInHandRenderer`, `PlayerItemInHandLayer`.
- [ ] 4. Быстрые модули (P1, размер S): TNTTimer, AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint,
PlaceAnimation, GodRays (референс-шейдеры `shaders/core/godrays`), ProjectileTrails (на нашем `Trails`), ArmorColor.
- [ ] 5. Дальше (P1-P2): DeathEffects (распад на части, шейдер `death_blast`), Blizzard, ElytraTrails, TotemSound, Sounds,
InventorySort, StorageTracker, ConsumeESP, CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase.
P3: Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer.
Пропустить: GuiPresence (нужен бэкенд evo). Полный список из 29 модулей с оценкой: отчёт, раздел 1.2.
- [ ] 6. Лицензия: `ref/evo/README.md` объявляет код и ассеты evo проприетарными. Перед релизом заменить на свои модели/
звуки или получить разрешение автора; до тех пор пути из отчёта только для локальной отладки, в репозиторий
ассеты evo не коммитить.
### Не проверено вживую / открытые вопросы
- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `<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 файлов >250 строк), по мере касания.
- [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде.
### Заметки для следующих сессий
- Игру запускает владелец: `cd mod && ./gradlew runClient`. Из корня репозитория `./gradlew` не существует.
- Параллельные gradle-сборки блокируют друг друга: форкам запускать через
`flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`.
- Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*`
(Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`.
- Лимиты проекта: ≤250 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении),
без длинного тире в текстах сайта и ответах.

View file

@ -0,0 +1,53 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
/** Builds the {@link MenuPresence} snapshot of the modules menu for the given mouse position. */
final class ModulesMenuPresence {
private static final int NO_MODULE = 255;
private ModulesMenuPresence() {
}
static MenuPresence snapshot(ModulesMenuScreen screen, float mx, float my, boolean pressed) {
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
for (ModulesMenuPanel p : screen.panels) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + p.w);
maxY = Math.max(maxY, p.y + p.h);
}
if (maxX <= minX || maxY <= minY) return null;
int category = -1;
int module = NO_MODULE;
boolean settings = false;
int scroll = 0;
for (ModulesMenuPanel p : screen.panels) {
if (!ModulesMenuScreen.inside(mx, my, p.x, p.y, p.w, p.h)) continue;
category = p.index;
settings = p.selected != null;
if (settings) {
scroll = ratio(p.settingsSmoothScroll, p.maxSettingsScroll);
break;
}
scroll = ratio(p.modulesSmoothScroll, p.maxModulesScroll);
for (int i = 0; i < p.hits.size() && i < NO_MODULE; i++) {
ModulesMenuPanel.ModuleHit h = p.hits.get(i);
if (ModulesMenuScreen.inside(mx, my, h.x(), h.y(), h.w(), h.h())) {
module = i;
break;
}
}
break;
}
float x = Math.max(0.0f, Math.min(1.0f, (mx - minX) / (maxX - minX)));
float y = Math.max(0.0f, Math.min(1.0f, (my - minY) / (maxY - minY)));
return new MenuPresence(category, module, settings, ClickGuiSearch.isActive(), x, y, pressed, scroll);
}
private static int ratio(float value, float max) {
return max <= 0.0f ? 0 : Math.max(0, Math.min(255, Math.round(Math.abs(value) / max * 255.0f)));
}
}

View file

@ -1,6 +1,7 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuCategory;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
@ -32,6 +33,8 @@ public final class ModulesMenuScreen {
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;
private static ModulesMenuScreen active;
private float lastMouseX; private float lastMouseY;
public ModulesMenuScreen() {
for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal()));
@ -48,12 +51,20 @@ public final class ModulesMenuScreen {
}
public boolean isVisible() { return openTarget || screenAnim > 0.001f; }
/** State of the currently shown modules menu for GUI presence, or null when no menu is open. */
public static MenuPresence presenceSnapshot(boolean mouseDown) {
ModulesMenuScreen s = active;
if (s == null || !s.openTarget || s.screenAnim < 0.6f) return null;
return ModulesMenuPresence.snapshot(s, s.lastMouseX, s.lastMouseY, mouseDown);
}
public void layout(float x, float y, float w, float h) {
this.areaX = x; this.areaY = y; this.areaW = Math.max(1.0f, w); this.areaH = Math.max(1.0f, h);
ModulesMenuLayout.layout(this, panels, areaX, areaY, areaW, areaH);
}
public void render(float mouseX, float mouseY) {
lastMouseX = mouseX; lastMouseY = mouseY; active = this;
ModulesMenuChromeRenderer.ensureFonts();
ModulesMenuStyle.syncTheme();
float dt = AnimationUtility.deltaTime();

View file

@ -0,0 +1,10 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model;
/**
* Snapshot of the local ClickGUI for GUI presence: hovered column, module row under the cursor (255 = none),
* whether that column shows a module's settings, search state, cursor position normalised to the panels' bounds
* (0..1), left button and list scroll (0..255).
*/
public record MenuPresence(int category, int moduleIndex, boolean settingsOpen, boolean searching,
float x, float y, boolean pressed, int scroll) {
}

View file

@ -14,7 +14,7 @@ import java.util.List;
* cursor and fades out when nothing is hovered.
*/
final class ModulesMenuDescriptionRenderer {
private static final int MAX_LINES = 3;
private static final int MAX_LINES = 2;
private ModulesMenuDescriptionRenderer() {
}
@ -35,25 +35,25 @@ final class ModulesMenuDescriptionRenderer {
ModulesMenuChromeRenderer.ensureFonts();
TextRenderer titleFont = ModulesMenuChromeRenderer.medium;
TextRenderer bodyFont = ModulesMenuChromeRenderer.regular;
float titleSize = 9.0f * scale;
float bodySize = 7.5f * scale;
float pad = 9.0f * scale;
float w = Math.min(screen.areaW * 0.6f, 300.0f * scale);
float titleSize = 7.4f * scale;
float bodySize = 6.2f * scale;
float pad = 6.0f * scale;
float w = Math.min(screen.areaW * 0.36f, 190.0f * scale);
List<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 h = pad * 2.0f + titleH + 2.0f * scale + lines.size() * lineH;
float x = screen.areaX + (screen.areaW - w) * 0.5f;
float y = Math.max(screen.areaY + 6.0f * scale, panelsTop(screen) - h - 10.0f * scale) + (1.0f - alpha) * 6.0f * scale;
float y = Math.max(screen.areaY + 6.0f * scale, panelsTop(screen) - h - 8.0f * scale) + (1.0f - alpha) * 6.0f * scale;
float radius = 8.0f * scale;
float radius = 6.0f * scale;
ModulesMenuGlassRenderer.drawLiquidGlass(x, y, w, h, radius, false, alpha >= 0.999f ? 1.0f : alpha, scale);
int veil = ModulesMenuLayout.withAlpha(ModulesMenuStyle.panelBgGlassDark(), alpha * 0.35f);
LayoutRender2D.roundedQuad(x, y, w, h, radius, veil, veil, veil, veil);
ClickGuiRenderer.drawText(titleFont, screen.descTitle, x + pad, y + pad, titleSize,
ModulesMenuLayout.withAlpha(ModulesMenuStyle.text(), alpha), false);
float ty = y + pad + titleH + 3.0f * scale;
float ty = y + pad + titleH + 2.0f * scale;
int bodyColor = ModulesMenuLayout.withAlpha(ModulesMenuStyle.textMuted(), alpha);
for (String line : lines) {
ClickGuiRenderer.drawText(bodyFont, line, x + pad, ty, bodySize, bodyColor, false);

View file

@ -60,6 +60,7 @@ public final class BetterTooltips extends AbstractHudElement {
private final BooleanValue itemInfoColorize = bool("item_info_colorize", true);
private final ModeValue tooltipFont = mode("tooltip_font", "Iosevka", TOOLTIP_FONT_OPTIONS);
private final NumberValue<Float> tooltipTextSize = num("tooltip_text_size", 1.0f, 0.5f, 2.0f);
private final BooleanValue tooltipAutoScale = bool("tooltip_auto_scale", true);
private final ItemIdSetValue topIgnore = registerTopIgnore();
private final NumberValue<Float> shulkerPreviewSlotSize =
visibleWhen(num("shulker_slot_size", 18.0f, 12.0f, 24.0f), this::isShulkerPreviewEnabled);
@ -101,6 +102,13 @@ public final class BetterTooltips extends AbstractHudElement {
}
String tooltipFontValue() { return tooltipFont.get(); }
float tooltipTextSizeValue() { return tooltipTextSize.get(); }
/** Множитель под масштаб интерфейса: эталон GUI Scale 3, при 2 тултип крупнее, при 4 мельче. */
float tooltipAutoScale() {
if (!tooltipAutoScale.get()) return 1.0f;
double gui = Math.max(1.0, net.minecraft.client.Minecraft.getInstance().getWindow().getGuiScale());
return (float) Math.max(0.7, Math.min(2.2, 3.0 / gui));
}
UiScriptModuleHandle tooltipPanelHandle() { return tooltipPanelHandle; }
CachedUiScriptRuntime tooltipRuntime() { return tooltipRuntime; }

View file

@ -127,19 +127,23 @@ final class TooltipPanelRenderer {
if (tr == null) return;
PreviewLayout preview = ShulkerPreviewCache.buildLayout();
List<TooltipLine> finalLines = TooltipState.prepareLines(lines, preview != null);
float scale = BetterTooltips.TOOLTIP_TEXT_SCALE * TooltipState.getScaleOverride() * instance.tooltipTextSizeValue();
float auto = instance.tooltipAutoScale();
float scale = BetterTooltips.TOOLTIP_TEXT_SCALE * TooltipState.getScaleOverride() * instance.tooltipTextSizeValue() * auto;
int padX = Math.round(BetterTooltips.TOOLTIP_PAD_X * auto);
int padY = Math.round(BetterTooltips.TOOLTIP_PAD_Y * auto);
int titleGap = Math.round(BetterTooltips.TOOLTIP_TITLE_GAP * auto);
tr.begin(scale, false, false);
int maxW = 0;
for (TooltipLine l : finalLines) maxW = Math.max(maxW, (int) Math.ceil(tr.getWidth(l.text(), false)));
int lh = (int) Math.ceil(tr.getHeight(false));
tr.end();
if (finalLines.isEmpty()) finalLines = List.of(new TooltipLine(" ", Theme.theme().textPrimary()));
int textH = finalLines.size() * lh + (finalLines.size() > 1 ? BetterTooltips.TOOLTIP_TITLE_GAP : 0) + Math.max(0, finalLines.size() - 2) * BetterTooltips.TOOLTIP_LINE_GAP;
int textH = finalLines.size() * lh + (finalLines.size() > 1 ? titleGap : 0) + Math.max(0, finalLines.size() - 2) * BetterTooltips.TOOLTIP_LINE_GAP;
int pw = preview != null ? preview.width() : 0;
int ph = preview != null ? preview.height() : 0;
int contentW = Math.max(maxW, pw);
int totalW = contentW + BetterTooltips.TOOLTIP_PAD_X * 2;
int totalH = BetterTooltips.TOOLTIP_PAD_Y * 2 + textH + (preview != null ? BetterTooltips.TOOLTIP_PREVIEW_GAP + ph : 0);
int totalW = contentW + padX * 2;
int totalH = padY * 2 + textH + (preview != null ? BetterTooltips.TOOLTIP_PREVIEW_GAP + ph : 0);
int x = mouseX, y = mouseY;
if (pos != null) {
Vector2ic p = pos.positionTooltip(mc.getWindow().getGuiScaledWidth(), mc.getWindow().getGuiScaledHeight(), mouseX, mouseY, totalW, totalH);
@ -157,20 +161,20 @@ final class TooltipPanelRenderer {
ViewportContext.beginScaled(ctx);
boolean rendered = renderScripted(mc, Renderer2D.COLOR, tr, x, y, totalW, totalH, bg, tl, trc, br, bl, glow, BetterTooltips.TOOLTIP_STROKE_WIDTH, BetterTooltips.TOOLTIP_GLOW_SIZE, 1.0f, instance.tooltipPanelHandle(), instance.tooltipRuntime(), ctx);
if (!rendered) drawFallback(x, y, totalW, totalH, bg, tl, trc, br, bl, glow);
int tx = x + BetterTooltips.TOOLTIP_PAD_X, ty = y + BetterTooltips.TOOLTIP_PAD_Y;
int tx = x + padX, ty = y + padY;
tr.begin(scale, false, false);
for (int i = 0; i < finalLines.size(); i++) {
TooltipLine l = finalLines.get(i);
tr.render(l.text(), tx, ty, new RenderColor(l.color()), true);
ty += lh;
if (i == 0 && finalLines.size() > 1) ty += BetterTooltips.TOOLTIP_TITLE_GAP; else if (i + 1 < finalLines.size()) ty += BetterTooltips.TOOLTIP_LINE_GAP;
if (i == 0 && finalLines.size() > 1) ty += titleGap; else if (i + 1 < finalLines.size()) ty += BetterTooltips.TOOLTIP_LINE_GAP;
}
tr.end();
ViewportContext.end(ctx);
if (preview != null) {
int cw = totalW - BetterTooltips.TOOLTIP_PAD_X * 2;
int px = x + BetterTooltips.TOOLTIP_PAD_X + Math.max(0, (cw - preview.width()) / 2);
int py = y + BetterTooltips.TOOLTIP_PAD_Y + textH + BetterTooltips.TOOLTIP_PREVIEW_GAP;
int cw = totalW - padX * 2;
int px = x + padX + Math.max(0, (cw - preview.width()) / 2);
int py = y + padY + textH + BetterTooltips.TOOLTIP_PREVIEW_GAP;
ShulkerPreviewRenderer.render(preview, px, py, ctx);
}
}

View file

@ -62,6 +62,8 @@ public class Predictions extends Module {
private final BooleanValue glowWallsValue = bool("pred_glow_walls", SETTING_GLOW_WALLS, false);
private final BooleanValue tntTimerValue = bool("pred_tnt_timer", SETTING_TNT_TIMER, true);
private final RGBColorValue tntColorValue = visibleWhen(colorNoAlpha("pred_tnt_color", SETTING_TNT_COLOR, "#FF5555"), tntTimerValue::get);
private final BooleanValue tntIconValue = visibleWhen(bool("pred_tnt_icon", "tnt_icon", true), tntTimerValue::get);
private final BooleanValue tntBarValue = visibleWhen(bool("pred_tnt_bar", "tnt_fuse_bar", true), tntTimerValue::get);
private final BooleanMapValue kindToggles = group("pred_kind_toggles", SETTING_PROJECTILES, new java.util.LinkedHashMap<>() {{
put("arrow", true); put("trident", true); put("fireball", true); put("wind_charge", true);
put("firework", true); put("snowball", true); put("egg", true);
@ -139,7 +141,9 @@ public class Predictions extends Module {
public BooleanValue getTargetHudOnAimValue() { return targetHudOnAimValue; }
public RGBColorValue getTntColorValue() { return tntColorValue; }
public List<TimerPlate> getTimerPlates() { return timerPlates; }
public boolean isTntBarEnabled() { return tntBarValue.get(); }
public ItemStack getTntTimerIcon() {
if (!tntIconValue.get()) return ItemStack.EMPTY;
if (tntTimerIcon.isEmpty()) tntTimerIcon = new ItemStack(Items.TNT);
return tntTimerIcon;
}

View file

@ -27,6 +27,7 @@ public final class PredictionsHudRenderer {
private static final float PLATE_TEXT_SCALE = 0.68f;
private static final float PLATE_TEXT_PADDING = 24.0f;
private static final float PLATE_HEIGHT = 16.0f;
private static final float TNT_FUSE_TICKS = 80.0f;
private static final float PLATE_BG_OFFSET_X = 2.0f;
private static final float PLATE_BG_OFFSET_Y = 0.0f;
private static final float PLATE_BG_SHRINK = 3.0f;
@ -62,9 +63,19 @@ public final class PredictionsHudRenderer {
double seconds = Math.max(0.0, (fuse - tickDelta) / 20.0);
drawTimerPlate(facade, renderer, textRenderer, formatTime(seconds),
(float) screen.x, (float) screen.y, plateColor, facade.getTntTimerIcon(), null);
if (facade.isTntBarEnabled()) drawFuseBar(renderer, (float) screen.x, (float) screen.y, (fuse - tickDelta) / TNT_FUSE_TICKS, plateColor);
}
}
private static void drawFuseBar(Renderer2D renderer, float cx, float plateY, float progress, int argb) {
float w = 30.0f;
float x = cx - w * 0.5f;
float y = plateY + PLATE_HEIGHT + 2.0f;
renderer.roundedRect(x, y, w, 2.5f, 1.2f, 0xA0000000);
float fill = w * Math.max(0.0f, Math.min(1.0f, progress));
if (fill > 1.0f) renderer.roundedRect(x, y, fill, 2.5f, 1.2f, argb | 0xFF000000);
}
public static void drawTimerPlate(Predictions facade, Renderer2D renderer, TextRenderer textRenderer,
String txt, float sx, float sy, int argb,
ItemStack iconStack, Identifier iconId) {

View file

@ -0,0 +1,44 @@
package dev.loki.lovisual.features.module.modules.player;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.util.combat.rotation.SprintController;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
/**
* Holds the sprint key for you while you move. Only the input is pressed, so vanilla
* rules (hunger, sneaking, blindness) and the shared {@link SprintController} still decide.
*/
@ModuleInfo(
id = "autosprint",
displayName = "Auto Sprint",
aliases = {"autosprint", "sprint"},
category = ModuleCategory.PLAYER
)
public class AutoSprint extends Module {
private final BooleanValue allDirections = bool("autoSprintAllDirections", "all_directions", false);
private final BooleanValue whileUsing = bool("autoSprintWhileUsing", "while_using", false);
@EventHandler
private void onSprintControl(SprintControlEvent event) {
if (event.getSource() != SprintControlEvent.Source.INPUT || event.shouldSprint()) return;
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return;
if (!wantsSprint(event.getForward(), event.getSideways(), allDirections.get(),
player.isShiftKeyDown(), player.isUsingItem(), whileUsing.get())) return;
if (SprintController.INSTANCE.canStartSprinting(player)) event.setSprint(true);
}
/** Pure decision: does the held-sprint input apply for this movement? */
static boolean wantsSprint(float forward, float sideways, boolean allDirections,
boolean sneaking, boolean usingItem, boolean whileUsing) {
if (sneaking) return false;
if (usingItem && !whileUsing) return false;
return allDirections ? forward != 0.0f || sideways != 0.0f : forward > 0.0f;
}
}

View file

@ -32,15 +32,6 @@ final class TrailsColor {
}
AnimatedRenderColors.Mode parse(String mode) {
return switch (mode) {
case "Rainbow" -> AnimatedRenderColors.Mode.RAINBOW;
case "LightRainbow" -> AnimatedRenderColors.Mode.LIGHT_RAINBOW;
case "Sky" -> AnimatedRenderColors.Mode.SKY;
case "Fade" -> AnimatedRenderColors.Mode.FADE;
case "DoubleColor" -> AnimatedRenderColors.Mode.DOUBLE_COLOR;
case "Analogous" -> AnimatedRenderColors.Mode.ANALOGOUS;
case "Theme" -> AnimatedRenderColors.Mode.THEME;
default -> AnimatedRenderColors.Mode.STATIC;
};
return AnimatedRenderColors.modeOf(mode);
}
}

View file

@ -0,0 +1,66 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonPoint;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
/** Вид ленты шлейфа элитры (по evo): горизонтальная лента с сужением, скоростью, плавным стартом и колебаниями. */
final class ElytraRibbonStyle implements TrailRibbon.Style {
static final float GLOW_WIDTH_SCALE = 2.4f;
private final RibbonShape.Taper taper;
private final float taperAmount;
private final RibbonShape.Motion motion;
private final float waveSize;
private final float waveSpeed;
private final boolean speedReactive;
private final boolean fadeStart;
private final boolean softEdges;
private final boolean glow;
ElytraRibbonStyle(RibbonShape.Taper taper, float taperAmount, RibbonShape.Motion motion, float waveSize,
float waveSpeed, boolean speedReactive, boolean fadeStart, boolean softEdges, boolean glow) {
this.taper = taper;
this.taperAmount = taperAmount;
this.motion = motion;
this.waveSize = waveSize;
this.waveSpeed = waveSpeed;
this.speedReactive = speedReactive;
this.fadeStart = fadeStart;
this.softEdges = softEdges;
this.glow = glow;
}
@Override
public boolean horizontal() {
return true;
}
@Override
public boolean softEdges() {
return softEdges;
}
@Override
public float glowScale() {
return glow ? GLOW_WIDTH_SCALE : 0.0f;
}
@Override
public double halfWidth(float width, int i, int n, RibbonPoint p, float life) {
return width * 0.5 * RibbonShape.taper(taper, taperAmount, RibbonShape.tailness(i, n))
* RibbonShape.speedWidth(speedReactive, p.speed());
}
@Override
public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) {
return life * RibbonShape.headRamp(fadeStart, i, n) * RibbonShape.nearFade(cameraDistance)
* RibbonShape.speedAlpha(speedReactive, p.speed());
}
@Override
public float[] motion(RibbonPoint p) {
return motion == RibbonShape.Motion.STRAIGHT ? null : RibbonShape.motion(motion, p.ms(), waveSize, waveSpeed);
}
}

View file

@ -0,0 +1,111 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.ElytraPoses;
import dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose.WingTipLocator;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.RibbonShape;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import java.util.List;
/** Светящиеся шлейфы с концов крыльев элитры в полёте (по мотивам evo ElytraTrails). */
@ModuleInfo(
id = "elytratrails",
displayName = "ElytraTrails",
category = ModuleCategory.VISUALS,
description = "Glowing trails streaming from elytra wing tips while gliding"
)
public class ElytraTrails extends Module {
private final WingTrailStore store = new WingTrailStore();
private static final double MAX_TRACK_DISTANCE_SQ = 16384.0;
private final BooleanValue own = bool("elytraTrailsOwn", "own", true);
private final BooleanValue others = bool("elytraTrailsOthers", "others", true);
private final NumberValue<Integer> duration = num("elytraTrailsDuration", "duration", 1200, 200, 4000);
private final NumberValue<Float> width = num("elytraTrailsWidth", "width", 0.22f, 0.02f, 1.5f);
private final ModeValue taper = modeSetting("elytraTrailsTaper", "taper", "Grow", "Uniform", "Grow", "Shrink");
private final NumberValue<Float> taperAmount = visibleWhen(
num("elytraTrailsTaperAmount", "taper_amount", 1.8f, 1.25f, 8.0f), () -> !"Uniform".equals(taper.get()));
private final ModeValue motion = modeSetting("elytraTrailsMotion", "motion", "Straight", "Straight", "Wave", "Helix");
private final NumberValue<Float> waveSize = visibleWhen(
num("elytraTrailsWaveSize", "wave_size", 0.35f, 0.05f, 2.0f), () -> !"Straight".equals(motion.get()));
private final NumberValue<Float> waveSpeed = visibleWhen(
num("elytraTrailsWaveSpeed", "wave_speed", 3.0f, 0.5f, 12.0f), () -> !"Straight".equals(motion.get()));
private final BooleanValue fadeStart = bool("elytraTrailsFadeStart", "fade_start", true);
private final BooleanValue speedReactive = bool("elytraTrailsSpeedReactive", "speed_reactive", true);
private final BooleanValue glow = bool("elytraTrailsGlow", "glow", true);
private final BooleanValue softEdges = bool("elytraTrailsSoftEdges", "soft_edges", true);
private final BooleanValue fill = bool("elytraTrailsFill", "fill", true);
private final BooleanValue lines = bool("elytraTrailsLines", "lines", false);
private final NumberValue<Float> lineWidth = visibleWhen(num("elytraTrailsLineWidth", "line_width", 2.0f, 1.0f, 5.0f), lines::get);
private final BooleanValue depthTest = bool("elytraTrailsDepth", "depth_test", true);
private final ModeValue colorMode = modeSetting("elytraTrailsColorMode", "color_mode", "DoubleColor",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme");
private final RGBAColorValue color = color("elytraTrailsColor", "color", "#88FF66CC");
private final RGBAColorValue color2 = color("elytraTrailsColor2", "color2", "#6688FFFF");
private final NumberValue<Integer> colorSpeed = num("elytraTrailsColorSpeed", "color_speed", 18, 2, 54);
private final NumberValue<Integer> colorPhase = num("elytraTrailsColorPhase", "color_phase", 0, 0, 360);
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
store.clear();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
long now = GameClock.millis();
long life = duration.get();
Vec3 cam = mc.gameRenderer.mainCamera().position();
for (Player player : mc.level.players()) {
if (!(player instanceof AbstractClientPlayer wearer)) continue;
if (!wearer.isFallFlying() || wearer.isInvisible() || !wearer.isAlive()) continue;
if (!(wearer == mc.player ? own.get() : others.get())) continue;
if (wearer.distanceToSqr(cam) > MAX_TRACK_DISTANCE_SQ) continue;
WingTipLocator.Tips tips = WingTipLocator.tips(ElytraPoses.of(wearer, tickDelta));
float speed = ElytraPoses.speed(wearer);
store.sample(wearer.getUUID(), WingTrailStore.LEFT, tips.left().x, tips.left().y, tips.left().z, now, life, speed);
store.sample(wearer.getUUID(), WingTrailStore.RIGHT, tips.right().x, tips.right().y, tips.right().z, now, life, speed);
}
store.prune(now, life);
List<TrackBuffer> tracks = store.snapshot();
if (tracks.isEmpty()) return;
int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f) + colorPhase.get();
int primary = color.getArgb();
int secondary = color2.getArgb();
int speed = colorSpeed.get();
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
ElytraRibbonStyle style = new ElytraRibbonStyle(RibbonShape.Taper.of(taper.get()), taperAmount.get(),
RibbonShape.Motion.of(motion.get()), waveSize.get(), waveSpeed.get(), speedReactive.get(),
fadeStart.get(), softEdges.get(), glow.get());
TrailRibbon.render(renderer, tracks, now, life, width.get(), fill.get(), false, lines.get(), lineWidth.get(),
depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed,
phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true),
style);
}
}

View file

@ -0,0 +1,66 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Истории концов крыльев: по ленте на каждое крыло каждого игрока; опустевшие ленты и игроки удаляются. */
final class WingTrailStore {
static final int LEFT = 0;
static final int RIGHT = 1;
private final Map<UUID, TrackBuffer[]> tracks = new HashMap<>();
void clear() {
tracks.clear();
}
/** Минимальный шаг записи (блоки), скачок и пауза, после которых лента начинается заново (как у evo). */
static final double MIN_STEP = 0.35;
static final double MAX_STEP = 8.0;
static final long GAP_MS = 300L;
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs) {
sample(id, side, x, y, z, now, lifeMs, 0.0f);
}
void sample(UUID id, int side, double x, double y, double z, long now, long lifeMs, float speed) {
TrackBuffer[] pair = tracks.computeIfAbsent(id, ignored -> new TrackBuffer[2]);
if (pair[side] == null) pair[side] = new TrackBuffer();
pair[side].addContinuous(x, y, z, now, lifeMs, speed, MIN_STEP, MAX_STEP, GAP_MS);
}
/** Убирает устаревшие точки; опустевшие ленты и игроки без лент удаляются. */
void prune(long now, long lifeMs) {
tracks.values().removeIf(pair -> {
boolean any = false;
for (int i = 0; i < pair.length; i++) {
if (pair[i] == null) continue;
pair[i].trim(now, lifeMs);
if (pair[i].isEmpty()) pair[i] = null;
else any = true;
}
return !any;
});
}
/** Живые ленты для отрисовки (до двух на игрока). */
List<TrackBuffer> snapshot() {
List<TrackBuffer> out = new ArrayList<>(tracks.size() * 2);
for (TrackBuffer[] pair : tracks.values()) {
for (TrackBuffer track : pair) {
if (track != null && !track.isEmpty()) out.add(track);
}
}
return out;
}
int trackCount() {
return tracks.size();
}
}

View file

@ -0,0 +1,29 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
/** Собирает {@link WingTipLocator.Pose} из живого игрока так же, как это делает отрисовка элитры. */
public final class ElytraPoses {
private ElytraPoses() {
}
public static WingTipLocator.Pose of(AbstractClientPlayer player, float partial) {
Vec3 pos = player.getPosition(partial);
Vec3 delta = player.avatarState().deltaMovementOnPreviousTick().lerp(player.getDeltaMovement(), partial);
Vec3 view = player.getViewVector(partial);
float flying = WingTipLocator.flyingYRot(delta.x, delta.z, view.x, view.z);
float bodyYaw = Mth.rotLerp(partial, player.yBodyRotO, player.yBodyRot);
var wing = player.elytraAnimationState;
return new WingTipLocator.Pose(pos.x, pos.y, pos.z, bodyYaw, player.getXRot(partial), player.getScale(),
player.isCrouching(), player.isAutoSpinAttack(), player.getFallFlyingTicks() + partial, flying,
player.tickCount + partial, wing.getRotX(partial), wing.getRotY(partial), wing.getRotZ(partial));
}
/** Скорость игрока за тик, блоки (для ширины и прозрачности ленты). */
public static float speed(AbstractClientPlayer player) {
return (float) player.getDeltaMovement().length();
}
}

View file

@ -0,0 +1,95 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.elytratrails.pose;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/**
* Концы крыльев элитры по настоящей модели: та же цепочка преобразований, что у отрисовки элитры на теле
* игрока (масштаб, поворот корпуса, наклон в полёте, поворот по траектории, крыло с анимацией взмаха),
* как в evo. Чистая математика без Minecraft-классов, кроме {@link Vec3}.
*/
public final class WingTipLocator {
static final float MODEL_SCALE = 0.9375f;
static final float BODY_OFFSET_Y = -1.501f;
static final float WINGS_OFFSET_Z = 0.125f;
static final float WING_PIVOT_X = 0.3125f;
static final float WING_CROUCH_Y = 0.1875f;
static final float TIP_X = 0.6875f;
static final float TIP_Y = 1.3125f;
static final float TIP_Z = 0.1875f;
static final float SPIN_ATTACK_DEGREES = -75.0f;
static final float FALL_FLYING_PITCH_OFFSET = -90.0f;
static final float BODY_ROT_OFFSET = 180.0f;
static final float FALL_FLYING_DIVISOR = 100.0f;
static final float FLIGHT_ROT_EPSILON = 1.0E-5f;
private static final float DEG = (float) (Math.PI / 180.0);
private WingTipLocator() {
}
/**
* Состояние игрока для расчёта.
*
* @param x,y,z интерполированная позиция (ноги)
* @param bodyYawDeg интерполированное рыскание корпуса, градусы
* @param xRotDeg наклон взгляда, градусы
* @param scale масштаб сущности (атрибут размера)
* @param spinAttack крутится трезубцем (riptide)
* @param fallFlyingTicks сколько тиков планирует (с долей кадра)
* @param flyingYRot см. {@link #flyingYRot}
* @param spinTime tickCount + partialTick для вращения riptide
* @param rotX,rotY,rotZ анимация крыла: у правого крыла знаки rotY и rotZ меняются
*/
public record Pose(double x, double y, double z, float bodyYawDeg, float xRotDeg, float scale,
boolean crouching, boolean spinAttack, float fallFlyingTicks, float flyingYRot,
float spinTime, float rotX, float rotY, float rotZ) {
}
public record Tips(Vec3 left, Vec3 right) {
}
/** Доля «лёжа»: 0 в начале планирования, 1 после десяти тиков. */
public static float fallFlyingScale(float fallFlyingTicks) {
float f = fallFlyingTicks * fallFlyingTicks / FALL_FLYING_DIVISOR;
return f < 0.0f ? 0.0f : Math.min(f, 1.0f);
}
/**
* Поворот корпуса вокруг вертикали при лёгком рысканьи: угол между горизонтальной скоростью и взглядом,
* со знаком по векторному произведению; 0, если игрок почти стоит или смотрит вертикально.
*/
public static float flyingYRot(double deltaX, double deltaZ, double viewX, double viewZ) {
double dl = deltaX * deltaX + deltaZ * deltaZ;
double vl = viewX * viewX + viewZ * viewZ;
if (dl <= 1.0E-5 || vl <= 1.0E-5) return 0.0f;
double dot = (deltaX * viewX + deltaZ * viewZ) / Math.sqrt(dl * vl);
double cross = deltaX * viewZ - deltaZ * viewX;
return (float) (Math.signum(cross) * Math.acos(Math.min(1.0, Math.abs(dot))));
}
public static Tips tips(Pose p) {
Matrix4f body = new Matrix4f().scale(p.scale()).rotateY((BODY_ROT_OFFSET - p.bodyYawDeg()) * DEG);
if (p.spinAttack()) {
body.rotateX((FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG).rotateY(p.spinTime() * SPIN_ATTACK_DEGREES * DEG);
} else {
body.rotateX(fallFlyingScale(p.fallFlyingTicks()) * (FALL_FLYING_PITCH_OFFSET - p.xRotDeg()) * DEG);
if (Math.abs(p.flyingYRot()) > FLIGHT_ROT_EPSILON) body.rotateY(p.flyingYRot());
}
body.scale(-1.0f, -1.0f, 1.0f).scale(MODEL_SCALE).translate(0.0f, BODY_OFFSET_Y, WINGS_OFFSET_Z);
float pivotY = p.crouching() ? WING_CROUCH_Y : 0.0f;
Vec3 left = tip(body, p, WING_PIVOT_X, pivotY, p.rotZ(), p.rotY(), p.rotX(), -TIP_X);
Vec3 right = tip(body, p, -WING_PIVOT_X, pivotY, -p.rotZ(), -p.rotY(), p.rotX(), TIP_X);
return new Tips(left, right);
}
private static Vec3 tip(Matrix4f body, Pose p, float pivotX, float pivotY, float rz, float ry, float rx,
float tipX) {
Matrix4f wing = new Matrix4f(body).translate(pivotX, pivotY, 0.0f).rotateZYX(rz, ry, rx);
Vector4f v = new Vector4f(tipX, TIP_Y, TIP_Z, 1.0f);
wing.transform(v);
return new Vec3(p.x() + v.x, p.y() + v.y, p.z() + v.z);
}
}

View file

@ -0,0 +1,39 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathEffectHelper;
/** Настройки режимов смерти KillEffect (Shatter, Ascend, Explosion, Disintegrate); вынесены из модуля ради лимита строк. */
abstract class DeathEffectSettings extends Module {
protected abstract String currentMode();
protected final boolean isMode(String value) {
return value.equals(currentMode());
}
protected final boolean isDeathMode() {
return DeathEffectHelper.handles(currentMode());
}
protected final NumberValue<Float> deathPower = visibleWhen(
num("killEffectDeathPower", "death_power", 1.0f, 0.3f, 3.0f), this::isDeathMode);
protected final NumberValue<Float> deathDuration = visibleWhen(
num("killEffectDeathDuration", "death_duration", 2.5f, 1.0f, 6.0f), this::isDeathMode);
protected final NumberValue<Float> deathSpin = visibleWhen(
num("killEffectDeathSpin", "death_spin", 1.0f, 0.2f, 3.0f), () -> isMode("Shatter") || isMode("Ascend"));
protected final BooleanValue deathBounce = visibleWhen(
bool("killEffectDeathBounce", "death_bounce", true), () -> isMode("Shatter"));
protected final NumberValue<Integer> deathSparks = visibleWhen(
num("killEffectDeathSparks", "death_sparks", 70, 10, 150), () -> isMode("Explosion"));
protected final ModeValue blastColorMode = visibleWhen(
modeSetting("killEffectBlastColor", "blast_color", "Natural", "Natural", "Client", "Custom"),
() -> isMode("Explosion"));
protected final boolean customBlastColor() {
return isMode("Explosion") && "Custom".equals(blastColorMode.get());
}
}

View file

@ -5,16 +5,16 @@ import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.blood.BloodKillHelper;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathEffectHelper;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.effects.FallingLavaHelper;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.wav.engine.CustomSoundEngine;
import net.minecraft.client.Minecraft;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
@ -25,12 +25,13 @@ import java.util.Map;
//todo Description
@ModuleInfo(id = "killeffect", displayName = "KillEffect", category = ModuleCategory.VISUALS)
public class KillEffect extends Module {
public class KillEffect extends DeathEffectSettings {
private static final Identifier ORTHODOX_SOUND =
Identifier.fromNamespaceAndPath("lovisual", "sounds/misc/orthodox.wav");
private final ModeValue mode = modeSetting("killEffectMode", "mode", "Orthodox",
"Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood");
"Orthodox", "FallingLava", "EmbersArc", "Lightning", "Portal", "Tornado", "Blood",
"Shatter", "Ascend", "Explosion", "Disintegrate");
private final NumberValue<Integer> ySpeed = visibleWhen(
num("killEffectYSpeed", "y_speed", 0, -10, 10), () -> isMode("Orthodox"));
private final BooleanValue playSound = visibleWhen(
@ -66,13 +67,15 @@ public class KillEffect extends Module {
private final KillLightningHelper lightningHelper = new KillLightningHelper();
private final KillBeamHelper beamHelper = new KillBeamHelper();
private final BloodKillHelper bloodHelper = new BloodKillHelper();
private final DeathEffectHelper deathHelper = new DeathEffectHelper();
private boolean isMode(String value) {
return value.equals(mode.get());
@Override
protected String currentMode() {
return mode.get();
}
private boolean usesColorSetting() {
return isMode("Orthodox") || isMode("Lightning") || usesBeamSettings();
return isMode("Orthodox") || isMode("Lightning") || usesBeamSettings() || customBlastColor();
}
private boolean usesBeamSettings() {
@ -126,7 +129,7 @@ public class KillEffect extends Module {
CustomSoundEngine.get().play(ORTHODOX_SOUND, soundVolume.get(), 1.0, false, true);
}
} else if ("FallingLava".equals(current)) {
spawnFallingLava(entity);
FallingLavaHelper.spawn(entity);
} else if ("EmbersArc".equals(current)) {
emberHelper.spawn(pos);
} else if ("Lightning".equals(current)) {
@ -135,6 +138,9 @@ public class KillEffect extends Module {
beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.PORTAL);
} else if ("Tornado".equals(current)) {
beamHelper.add(pos, color.getArgb(), KillBeamHelper.BeamKind.TORNADO);
} else if (DeathEffectHelper.handles(current)) {
deathHelper.spawn(entity, DeathEffectHelper.styleOf(current), deathPower.get(), deathDuration.get(),
deathSpin.get(), deathBounce.get(), deathSparks.get(), blastColor());
} else if ("Blood".equals(current)) {
bloodHelper.spawn(new Vec3(pos.x, pos.y + entity.getBbHeight() * 0.6, pos.z),
Math.clamp(entity.getBbWidth(), 0.3f, 1.6f), Math.clamp(entity.getBbHeight(), 0.3f, 2.6f),
@ -151,6 +157,7 @@ public class KillEffect extends Module {
lightningHelper.clear();
beamHelper.clear();
bloodHelper.clear();
deathHelper.clear();
}
@Override
@ -167,28 +174,16 @@ public class KillEffect extends Module {
} else if ("EmbersArc".equals(current)) {
emberHelper.render(renderer, tickDelta);
}
deathHelper.render(renderer, tickDelta);
beamHelper.render(renderer, mc, beamIntensity.get(), beamSize.get());
bloodHelper.render(renderer, mc.gameRenderer.mainCamera().position(), tickDelta, bloodTime.get());
}
private void spawnFallingLava(LivingEntity entity) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) {
return;
}
int height = (int) (entity.getBbHeight() * 10);
int width = (int) (entity.getBbWidth() * 10);
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
for (int k = 0; k < width; k++) {
((ClientLevelAccessor) mc.level).invokeAddParticle(
ParticleTypes.FALLING_LAVA, false, true,
entity.getX() + j * 0.1,
entity.getY() + i * 0.1,
entity.getZ() + k * 0.1,
0, 0, 0);
}
}
}
private int blastColor() {
return switch (blastColorMode.get()) {
case "Client" -> Theme.theme().accent() | 0xFF000000;
case "Custom" -> color.getArgb();
default -> 0xFFFFA030;
};
}
}

View file

@ -0,0 +1,54 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death;
import net.minecraft.world.entity.LivingEntity;
import java.util.ArrayList;
import java.util.List;
/** Разбиение тела на 6 частей (голова, торс, две руки, две ноги) с UV-прямоугольниками стандартного скина 64x64. */
public final class DeathBody {
/** Размеры в пикселях (w,h,d), начало UV (u,v) и смещение центра в блоках: kind -> {w,h,d,u,v,cx,cy}. */
private static final float[][] LAYOUT = {
{8, 8, 8, 0, 0, 0.0f, 1.75f},
{8, 12, 4, 16, 16, 0.0f, 1.125f},
{4, 12, 4, 40, 16, -0.375f, 1.125f},
{4, 12, 4, 32, 48, 0.375f, 1.125f},
{4, 12, 4, 0, 16, -0.125f, 0.375f},
{4, 12, 4, 16, 48, 0.125f, 0.375f},
};
private DeathBody() {
}
public static float[] layout(int kind) {
return LAYOUT[kind];
}
/**
* Части в мировых координатах: поза «стоя», повёрнутая по yaw тела, масштаб по росту сущности.
* Центр тела считается на высоте груди.
*/
public static List<DeathPhysics.Part> build(double px, double py, double pz, float bodyYawDeg, float height) {
float scale = Math.max(0.3f, height / 1.8f);
double yaw = Math.toRadians(-bodyYawDeg);
float cos = (float) Math.cos(yaw);
float sin = (float) Math.sin(yaw);
List<DeathPhysics.Part> parts = new ArrayList<>(LAYOUT.length);
for (int i = 0; i < LAYOUT.length; i++) {
float[] l = LAYOUT[i];
float lx = l[5] * scale;
float wx = lx * cos;
float wz = -lx * sin;
DeathPhysics.Part p = new DeathPhysics.Part(i, (float) px + wx, (float) py + l[6] * scale, (float) pz + wz,
l[0] / 32.0f * scale, l[1] / 32.0f * scale, l[2] / 32.0f * scale);
p.ry = (float) yaw;
parts.add(p);
}
return parts;
}
public static float height(LivingEntity e) {
return e.getBbHeight();
}
}

View file

@ -0,0 +1,178 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Part;
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.death.DeathPhysics.Style;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.player.health.PlayerSkinResolver;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity;
/** Режимы смерти из evo: тело рассыпается на части (Shatter/Explosion), взлетает (Ascend) или тает (Disintegrate). */
public final class DeathEffectHelper {
private static final int FALLBACK_TINT = 0xFF9A9A9A;
private static final float SPARK_LIFE = 0.9f;
private static final float FLASH_LIFE = 0.45f;
private static final class Spark {
float x, y, z, vx, vy, vz;
}
private static final class Corpse {
final List<Part> parts;
final List<Spark> sparks = new ArrayList<>();
final Style style;
final long start;
final float duration;
final float groundY;
final boolean bounce;
final Identifier skin;
final int sparkColor;
final float[] center;
long last;
Corpse(List<Part> parts, Style style, long start, float duration, float groundY, boolean bounce,
Identifier skin, int sparkColor, float[] center) {
this.parts = parts;
this.style = style;
this.start = start;
this.last = start;
this.duration = duration;
this.groundY = groundY;
this.bounce = bounce;
this.skin = skin;
this.sparkColor = sparkColor;
this.center = center;
}
}
private final List<Corpse> corpses = new ArrayList<>();
private final Random random = new Random();
public static Style styleOf(String mode) {
return switch (mode) {
case "Ascend" -> Style.ASCEND;
case "Explosion" -> Style.EXPLOSION;
case "Disintegrate" -> Style.DISINTEGRATE;
default -> Style.SHATTER;
};
}
public static boolean handles(String mode) {
return "Shatter".equals(mode) || "Ascend".equals(mode) || "Explosion".equals(mode) || "Disintegrate".equals(mode);
}
public void clear() {
corpses.clear();
}
public void spawn(LivingEntity e, Style style, float power, float duration, float spin, boolean bounce,
int sparks, int sparkColor) {
float height = DeathBody.height(e);
List<Part> parts = DeathBody.build(e.getX(), e.getY(), e.getZ(), e.yBodyRot, height);
float cx = (float) e.getX(), cy = (float) e.getY() + height * 0.6f, cz = (float) e.getZ();
for (Part p : parts) DeathPhysics.launch(p, style, power, spin, duration, cx, cy, cz, random);
Identifier skin = e instanceof AbstractClientPlayer player ? PlayerSkinResolver.resolvePlayerSkin(player) : null;
Corpse c = new Corpse(parts, style, GameClock.millis(), duration, (float) e.getY(), bounce, skin, sparkColor,
new float[]{cx, cy, cz});
if (style == Style.EXPLOSION) {
for (int i = 0; i < sparks; i++) c.sparks.add(spark(cx, cy, cz, power));
}
corpses.add(c);
}
private Spark spark(float cx, float cy, float cz, float power) {
Spark s = new Spark();
s.x = cx;
s.y = cy;
s.z = cz;
float theta = random.nextFloat() * 6.2831855f;
float up = random.nextFloat() * 2.0f - 0.6f;
float speed = (2.0f + random.nextFloat() * 5.0f) * power;
float horiz = (float) Math.sqrt(Math.max(0.0, 1.0 - up * up * 0.25));
s.vx = (float) Math.cos(theta) * horiz * speed;
s.vz = (float) Math.sin(theta) * horiz * speed;
s.vy = up * speed;
return s;
}
public void render(Renderer3D renderer, float tickDelta) {
if (corpses.isEmpty() || renderer == null) return;
long now = GameClock.millis();
for (Iterator<Corpse> it = corpses.iterator(); it.hasNext(); ) {
Corpse c = it.next();
float t = (now - c.start) / 1000.0f;
if (t > c.duration + 0.2f) {
it.remove();
continue;
}
advance(c, now, t);
drawParts(renderer, c, t);
if (c.style == Style.EXPLOSION) drawSparks(renderer, c, t);
}
}
private void advance(Corpse c, long now, float t) {
float dt = Math.min(0.1f, (now - c.last) / 1000.0f);
c.last = now;
int steps = Math.max(1, (int) Math.ceil(dt / 0.02f));
float h = dt / steps;
for (int i = 0; i < steps; i++) {
for (Part p : c.parts) DeathPhysics.step(p, c.style, h, t, c.groundY, c.bounce);
}
for (Spark s : c.sparks) {
s.vy -= 9.0f * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.z += s.vz * dt;
}
}
private void drawParts(Renderer3D renderer, Corpse c, float t) {
boolean textured = c.skin != null;
MeshBuilder mesh = textured
? renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_DEPTH, c.skin, Renderer3D.DepthMode.MAIN)
: renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
for (Part p : c.parts) {
float alpha = DeathPhysics.alpha(p, c.style, t, c.duration);
if (alpha > 0.01f) DeathMesh.draw(mesh, p, DeathBody.layout(p.kind), textured ? 0xFFFFFF : FALLBACK_TINT & 0xFFFFFF, alpha, textured);
}
}
private void drawSparks(Renderer3D renderer, Corpse c, float t) {
MeshBuilder glow = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.BLOOM,
Renderer3D.DepthMode.NONE);
if (glow == null) return;
Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation);
Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation);
if (t < FLASH_LIFE) {
float k = t / FLASH_LIFE;
float size = 0.6f + k * 3.2f;
int col = applyOpacity(c.sparkColor, (1.0f - k) * (1.0f - k));
addBillboardVertices(glow, c.center[0], c.center[1], c.center[2], new Vector3f(right).mul(size),
new Vector3f(up).mul(size), col, col, col, col);
}
float fade = Math.max(0.0f, 1.0f - t / SPARK_LIFE);
if (fade <= 0.0f) return;
int col = applyOpacity(c.sparkColor, fade);
Vector3f r = new Vector3f(right).mul(0.07f);
Vector3f u = new Vector3f(up).mul(0.07f);
for (Spark s : c.sparks) addBillboardVertices(glow, s.x, s.y, s.z, r, u, col, col, col, col);
}
}

View file

@ -0,0 +1,94 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
/**
* Рисует часть тела как куб с UV скина. Мировые пайплайны рисуют без отсечения задних граней,
* поэтому порядок обхода вершин не важен.
*/
final class DeathMesh {
private static final float TEX = 64.0f;
/** Яркость граней: +x, -x, +y, -y, +z, -z. */
private static final float[] SHADE = {0.8f, 0.8f, 1.0f, 0.55f, 0.9f, 0.7f};
private DeathMesh() {
}
static void draw(MeshBuilder mesh, DeathPhysics.Part p, float[] layout, int rgb, float alpha, boolean textured) {
float w = p.hw, h = p.hh, d = p.hd;
float u = layout[3], v = layout[4];
float pw = layout[0], ph = layout[1], pd = layout[2];
float[][] c = corners(p, w, h, d);
// порядок углов: 0:-x-y-z 1:+x-y-z 2:+x+y-z 3:-x+y-z 4:-x-y+z 5:+x-y+z 6:+x+y+z 7:-x+y+z
face(mesh, c, 4, 5, 6, 7, u + pd, v + pd, pw, ph, rgb, alpha, SHADE[4], textured); // +z (перед)
face(mesh, c, 1, 0, 3, 2, u + pd + pw + pd, v + pd, pw, ph, rgb, alpha, SHADE[5], textured); // -z (спина)
face(mesh, c, 0, 4, 7, 3, u, v + pd, pd, ph, rgb, alpha, SHADE[1], textured); // -x (правая сторона)
face(mesh, c, 5, 1, 2, 6, u + pd + pw, v + pd, pd, ph, rgb, alpha, SHADE[0], textured); // +x (левая сторона)
face(mesh, c, 7, 6, 2, 3, u + pd, v, pw, pd, rgb, alpha, SHADE[2], textured); // +y (верх)
face(mesh, c, 0, 1, 5, 4, u + pd + pw, v, pw, pd, rgb, alpha, SHADE[3], textured); // -y (низ)
}
/** Углы куба после поворота Эйлера (Y, затем X, затем Z) и переноса в центр части. */
private static float[][] corners(DeathPhysics.Part p, float w, float h, float d) {
float[][] out = new float[8][3];
for (int i = 0; i < 8; i++) {
float x = (i & 1) == 0 ? -w : w;
float y = (i & 2) == 0 ? -h : h;
float z = (i & 4) == 0 ? -d : d;
// индексация: бит0 = x, бит1 = y, бит2 = z; пересобираем в порядок из draw()
out[i] = rotate(p, x, y, z);
}
return remap(out);
}
private static float[][] remap(float[][] bits) {
// bits[i]: i = x | y<<1 | z<<2. Нужен порядок 0:(-,-,-) 1:(+,-,-) 2:(+,+,-) 3:(-,+,-) 4:(-,-,+) 5:(+,-,+) 6:(+,+,+) 7:(-,+,+)
return new float[][]{bits[0], bits[1], bits[3], bits[2], bits[4], bits[5], bits[7], bits[6]};
}
private static float[] rotate(DeathPhysics.Part p, float x, float y, float z) {
float cy = (float) Math.cos(p.ry), sy = (float) Math.sin(p.ry);
float x1 = x * cy + z * sy, z1 = -x * sy + z * cy;
float cx = (float) Math.cos(p.rx), sx = (float) Math.sin(p.rx);
float y2 = y * cx - z1 * sx, z2 = y * sx + z1 * cx;
float cz = (float) Math.cos(p.rz), sz = (float) Math.sin(p.rz);
float x3 = x1 * cz - y2 * sz, y3 = x1 * sz + y2 * cz;
return new float[]{p.x + x3, p.y + y3, p.z + z2};
}
private static void face(MeshBuilder m, float[][] c, int a, int b, int cc, int dd, float u, float v,
float uw, float vh, int rgb, float alpha, float shade, boolean textured) {
int argb = argb(rgb, alpha, shade);
float u0 = u / TEX, u1 = (u + uw) / TEX, v0 = v / TEX, v1 = (v + vh) / TEX;
// a: низ-слева, b: низ-справа, cc: верх-справа, dd: верх-слева (для боковых граней)
quad(m, c[a], c[b], c[cc], c[dd], u0, v1, u1, v1, u1, v0, u0, v0, argb, textured);
}
private static void quad(MeshBuilder m, float[] p1, float[] p2, float[] p3, float[] p4,
float ua, float va, float ub, float vb, float uc, float vc, float ud, float vd,
int argb, boolean textured) {
m.ensureQuadCapacity();
RenderColor col = new RenderColor(argb);
int i1 = vertex(m, p1, ua, va, col, textured);
int i2 = vertex(m, p2, ub, vb, col, textured);
int i3 = vertex(m, p3, uc, vc, col, textured);
int i4 = vertex(m, p4, ud, vd, col, textured);
m.quad(i1, i2, i3, i4);
}
private static int vertex(MeshBuilder m, float[] p, float u, float v, RenderColor col, boolean textured) {
var b = m.vec3(p[0], p[1], p[2]);
if (textured) b = b.vec2(u, v);
return b.color(col).next();
}
private static int argb(int rgb, float alpha, float shade) {
int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
int r = Math.round(((rgb >> 16) & 0xFF) * shade);
int g = Math.round(((rgb >> 8) & 0xFF) * shade);
int bl = Math.round((rgb & 0xFF) * shade);
return (a << 24) | (r << 16) | (g << 8) | bl;
}
}

View file

@ -0,0 +1,137 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.death;
import java.util.Random;
/** Чистая физика частей тела для режимов смерти (Shatter, Ascend, Explosion, Disintegrate); без Minecraft. */
public final class DeathPhysics {
public enum Style { SHATTER, ASCEND, EXPLOSION, DISINTEGRATE }
/** Часть тела: центр, скорость, углы Эйлера (рад), угловая скорость, половинные размеры, задержка. */
public static final class Part {
public float x, y, z, vx, vy, vz;
public float rx, ry, rz, wx, wy, wz;
public final float hw, hh, hd;
public final int kind;
public float delay;
public float phase;
boolean resting;
public Part(int kind, float x, float y, float z, float hw, float hh, float hd) {
this.kind = kind;
this.x = x;
this.y = y;
this.z = z;
this.hw = hw;
this.hh = hh;
this.hd = hd;
}
float radius() {
return Math.max(hw, Math.max(hh, hd));
}
}
private static final float GRAVITY = 16.0f;
private DeathPhysics() {
}
/** Начальный импульс части относительно центра тела (cx, cy, cz). */
public static void launch(Part p, Style style, float power, float spin, float duration,
float cx, float cy, float cz, Random r) {
float dx = p.x - cx + (r.nextFloat() - 0.5f) * 0.2f;
float dy = p.y - cy;
float dz = p.z - cz + (r.nextFloat() - 0.5f) * 0.2f;
float len = Math.max(0.05f, (float) Math.sqrt(dx * dx + dy * dy + dz * dz));
p.phase = r.nextFloat() * 6.2831855f;
switch (style) {
case SHATTER -> {
float k = (1.5f + r.nextFloat() * 1.5f) * power;
set(p, dx / len * k, 2.5f * power + dy / len * k, dz / len * k, spin * 7.0f, r);
}
case EXPLOSION -> {
float k = (5.0f + r.nextFloat() * 3.0f) * power;
set(p, dx / len * k, 3.0f * power + dy / len * k * 0.6f, dz / len * k, spin * 12.0f, r);
}
case ASCEND -> set(p, 0.0f, (0.5f + r.nextFloat() * 0.4f) * power, 0.0f, spin * 1.2f, r);
default -> {
set(p, 0.0f, 0.0f, 0.0f, spin * 0.5f, r);
p.delay = Math.max(0.0f, (2.0f - p.y) / 2.0f) * duration * 0.45f;
}
}
}
private static void set(Part p, float vx, float vy, float vz, float spin, Random r) {
p.vx = vx;
p.vy = vy;
p.vz = vz;
p.wx = (r.nextFloat() - 0.5f) * 2.0f * spin;
p.wy = (r.nextFloat() - 0.5f) * 2.0f * spin;
p.wz = (r.nextFloat() - 0.5f) * 2.0f * spin;
}
/** Шаг физики; t: секунды от начала эффекта, groundY: уровень пола, bounce: отскок от земли. */
public static void step(Part p, Style style, float dt, float t, float groundY, boolean bounce) {
if (p.resting) return;
switch (style) {
case ASCEND -> {
p.x += (float) Math.sin(t * 2.0f + p.phase) * 0.25f * dt;
p.y += p.vy * dt;
spinStep(p, dt);
}
case DISINTEGRATE -> {
if (t > p.delay) p.y += 0.6f * dt;
spinStep(p, dt);
}
default -> ballistic(p, dt, groundY, bounce);
}
}
private static void ballistic(Part p, float dt, float groundY, boolean bounce) {
p.vy -= GRAVITY * dt;
float drag = (float) Math.pow(0.985, dt * 60.0);
p.vx *= drag;
p.vz *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
spinStep(p, dt);
float floor = groundY + p.radius();
if (p.y >= floor) return;
p.y = floor;
if (bounce && Math.abs(p.vy) > 1.0f) {
p.vy = -p.vy * 0.45f;
p.vx *= 0.8f;
p.vz *= 0.8f;
p.wx *= 0.7f;
p.wy *= 0.7f;
p.wz *= 0.7f;
} else {
p.vx = p.vy = p.vz = 0.0f;
p.wx = p.wy = p.wz = 0.0f;
p.resting = true;
}
}
private static void spinStep(Part p, float dt) {
p.rx += p.wx * dt;
p.ry += p.wy * dt;
p.rz += p.wz * dt;
}
/** Непрозрачность части в момент t (0..1). */
public static float alpha(Part p, Style style, float t, float duration) {
float a = switch (style) {
case SHATTER -> 1.0f - ramp(t, duration * 0.6f, duration * 0.4f);
case EXPLOSION -> 1.0f - ramp(t, duration * 0.4f, duration * 0.6f);
case ASCEND -> 1.0f - ramp(t, duration * 0.3f, duration * 0.7f);
default -> 1.0f - ramp(t, p.delay, duration * 0.35f);
};
return Math.max(0.0f, Math.min(1.0f, a));
}
private static float ramp(float t, float start, float length) {
return Math.max(0.0f, Math.min(1.0f, (t - start) / Math.max(0.001f, length)));
}
}

View file

@ -0,0 +1,28 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.hits.effects;
import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor;
import net.minecraft.client.Minecraft;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.world.entity.LivingEntity;
/** Лавовые капли по объёму тела (режим FallingLava у KillEffect). */
public final class FallingLavaHelper {
private FallingLavaHelper() {
}
public static void spawn(LivingEntity entity) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return;
int height = (int) (entity.getBbHeight() * 10);
int width = (int) (entity.getBbWidth() * 10);
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
for (int k = 0; k < width; k++) {
((ClientLevelAccessor) mc.level).invokeAddParticle(ParticleTypes.FALLING_LAVA, false, true,
entity.getX() + j * 0.1, entity.getY() + i * 0.1, entity.getZ() + k * 0.1, 0, 0, 0);
}
}
}
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrailRibbon;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import java.util.Map;
/** Затухающие ленты за стрелами, жемчугом, трезубцами и другими снарядами. */
@ModuleInfo(
id = "projectiletrails",
displayName = "ProjectileTrails",
category = ModuleCategory.VISUALS,
description = "Draws fading trails behind arrows, pearls and other projectiles"
)
public class ProjectileTrails extends Module {
private final TrailStore store = new TrailStore();
private final NumberValue<Integer> duration = num("projectileTrailsDuration", "duration", 500, 100, 2000);
private final NumberValue<Float> width = num("projectileTrailsWidth", "width", 0.15f, 0.05f, 1.5f);
private final BooleanValue fill = bool("projectileTrailsFill", "fill", true);
private final ModeValue fillType = visibleWhen(modeSetting("projectileTrailsFillType", "fill_type", "Solid", "Solid", "Glow"), fill::get);
private final BooleanValue lines = bool("projectileTrailsLines", "lines", true);
private final NumberValue<Float> lineWidth = visibleWhen(num("projectileTrailsLineWidth", "line_width", 2.0f, 1.0f, 5.0f), lines::get);
private final BooleanValue depthTest = bool("projectileTrailsDepth", "depth_test", true);
private final ModeValue colorMode = modeSetting("projectileTrailsColorMode", "color_mode", "Theme",
"Static", "Rainbow", "LightRainbow", "Sky", "Fade", "DoubleColor", "Analogous", "Theme");
private final RGBAColorValue color = color("projectileTrailsColor", "color", "#CCFF55FF");
private final RGBAColorValue color2 = color("projectileTrailsColor2", "color2", "#CC55FFFF");
private final NumberValue<Integer> colorSpeed = num("projectileTrailsColorSpeed", "color_speed", 18, 2, 54);
private final BooleanMapValue kinds = group("projectileTrailsKinds", "projectiles", Map.of(
"arrow", true, "trident", true, "pearl", true, "snowball", true, "egg", true,
"potion", true, "fireball", true, "wind_charge", true, "firework", false));
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
store.clear();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.gameRenderer.mainCamera() == null) return;
long now = GameClock.millis();
long life = duration.get();
for (Entity e : mc.level.entitiesForRendering()) {
String kind = TrailStore.kindOf(e);
if (kind != null && kinds.get(kind) && e.isAlive()) store.sample(e, tickDelta, now, life);
}
store.prune(now, life, id -> mc.level.getEntity(id) != null);
if (store.tracks().isEmpty()) return;
int phase = Math.round((mc.player == null ? 0 : mc.player.tickCount + tickDelta) * 4.0f);
AnimatedRenderColors.Mode mode = AnimatedRenderColors.modeOf(colorMode.get());
int primary = color.getArgb();
int secondary = color2.getArgb();
int speed = colorSpeed.get();
Vec3 cam = mc.gameRenderer.mainCamera().position();
TrailRibbon.render(renderer, store.tracks().values(), now, life, width.get(), fill.get(), "Glow".equals(fillType.get()),
lines.get(), lineWidth.get(), depthTest.get(), cam,
(i, n) -> AnimatedRenderColors.resolve(mode, speed, phase + Math.round(i / (float) Math.max(1, n - 1) * 180), primary, secondary, true));
}
}

View file

@ -0,0 +1,65 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.projectiletrails;
import dev.loki.lovisual.features.module.modules.visuals.effects.ribbon.TrackBuffer;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.phys.Vec3;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.function.IntPredicate;
/** История позиций летящих снарядов по id сущности; пропавшие из мира снаряды дотлевают и удаляются. */
final class TrailStore {
private final Map<Integer, TrackBuffer> tracks = new HashMap<>();
Map<Integer, TrackBuffer> tracks() {
return tracks;
}
void clear() {
tracks.clear();
}
/** Ключ вида снаряда для настроек: arrow, trident, pearl, snowball, egg, potion, fireball, wind_charge, firework. */
static String kindOf(Entity entity) {
if (!(entity instanceof Projectile)) return null;
return kindOfPath(BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).getPath());
}
/** Вид снаряда по пути id типа сущности (чистая логика); null для неподдерживаемых. */
static String kindOfPath(String path) {
if (path.contains("arrow")) return "arrow";
if (path.contains("trident")) return "trident";
if (path.contains("pearl")) return "pearl";
if (path.contains("snowball")) return "snowball";
if (path.contains("egg")) return "egg";
if (path.contains("potion")) return "potion";
if (path.contains("wind_charge")) return "wind_charge";
if (path.contains("fireball") || path.contains("fire_charge")) return "fireball";
if (path.contains("firework")) return "firework";
return null;
}
/** Добавляет текущую (интерполированную) позицию снаряда и отбрасывает старые точки. */
void sample(Entity entity, float partialTicks, long now, long lifeMs) {
Vec3 p = entity.getPosition(partialTicks);
sample(entity.getId(), p.x, p.y, p.z, now, lifeMs);
}
void sample(int id, double x, double y, double z, long now, long lifeMs) {
tracks.computeIfAbsent(id, key -> new TrackBuffer()).add(x, y, z, now, lifeMs);
}
/** Чистит следы: пропавшие снаряды дотлевают, потом след удаляется. */
void prune(long now, long lifeMs, IntPredicate alive) {
for (Iterator<Map.Entry<Integer, TrackBuffer>> it = tracks.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<Integer, TrackBuffer> e = it.next();
if (!alive.test(e.getKey())) e.getValue().trim(now, lifeMs);
if (e.getValue().isEmpty()) it.remove();
}
}
}

View file

@ -0,0 +1,9 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
/** Точка следа: позиция в мире, время (мс) появления и скорость источника в этот момент. */
public record RibbonPoint(double x, double y, double z, long ms, float speed) {
public RibbonPoint(double x, double y, double z, long ms) {
this(x, y, z, ms, 0.0f);
}
}

View file

@ -0,0 +1,112 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import java.util.Locale;
/**
* Форма ленты шлейфа (как у evo ElytraTrails): сужение вдоль следа, реакция на скорость, плавное начало,
* затухание у камеры и колебания. Чистые функции, без зависимостей от Minecraft.
*/
public final class RibbonShape {
static final float SPEED_MIN = 0.35f;
static final float SPEED_MAX = 1.6f;
static final float SPEED_WIDTH_FLOOR = 0.45f;
static final float SPEED_ALPHA_FLOOR = 0.25f;
static final float HEAD_RAMP_POINTS = 3.0f;
static final float NEAR_FADE_START = 0.75f;
static final float NEAR_FADE_RANGE = 1.5f;
private static final float TAU = (float) (Math.PI * 2.0);
private RibbonShape() {
}
/** Как меняется ширина от головы к хвосту. */
public enum Taper {
UNIFORM, GROW, SHRINK;
public static Taper of(String name) {
if (name == null) return UNIFORM;
return switch (name.toLowerCase(Locale.ROOT)) {
case "grow" -> GROW;
case "shrink" -> SHRINK;
default -> UNIFORM;
};
}
}
/** Форма пути, в которую «вплетена» лента. */
public enum Motion {
STRAIGHT, WAVE, HELIX;
public static Motion of(String name) {
if (name == null) return STRAIGHT;
return switch (name.toLowerCase(Locale.ROOT)) {
case "wave" -> WAVE;
case "helix" -> HELIX;
default -> STRAIGHT;
};
}
}
/** Удалённость точки от головы: 0 у самой новой, 1 у самой старой. */
public static float tailness(int index, int count) {
if (count <= 1) return 0.0f;
return 1.0f - index / (float) (count - 1);
}
/** Множитель ширины: у головы 1, у хвоста {@code amount} (Grow) или {@code 1/amount} (Shrink). */
public static float taper(Taper mode, float amount, float tailness) {
return switch (mode) {
case GROW -> 1.0f + tailness * (amount - 1.0f);
case SHRINK -> 1.0f + tailness * (1.0f / amount - 1.0f);
default -> 1.0f;
};
}
/** 0 на медленном планировании, 1 на полной скорости. */
public static float speedFactor(float speed) {
float v = (speed - SPEED_MIN) / (SPEED_MAX - SPEED_MIN);
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
}
public static float speedWidth(boolean reactive, float speed) {
return reactive ? SPEED_WIDTH_FLOOR + (1.0f - SPEED_WIDTH_FLOOR) * speedFactor(speed) : 1.0f;
}
public static float speedAlpha(boolean reactive, float speed) {
return reactive ? SPEED_ALPHA_FLOOR + (1.0f - SPEED_ALPHA_FLOOR) * speedFactor(speed) : 1.0f;
}
/** Плавное начало: самые новые точки прозрачны и проявляются за три точки. */
public static float headRamp(boolean fadeStart, int index, int count) {
if (!fadeStart) return 1.0f;
float v = (count - 1 - index) / HEAD_RAMP_POINTS;
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
}
/** Лента гаснет, когда камера вплотную к ней. */
public static float nearFade(float distance) {
float v = (distance - NEAR_FADE_START) / NEAR_FADE_RANGE;
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
}
/** Оставшаяся жизнь точки, линейно от 1 до 0. */
public static float life(long now, long birthMs, long lifeMs) {
if (lifeMs <= 0) return 0.0f;
float v = 1.0f - (now - birthMs) / (float) lifeMs;
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
}
/**
* Смещение точки от оси пути: {в сторону, по бинормали}. Зависит от времени рождения точки,
* поэтому узор едет вместе с точкой, а не пляшет на месте.
*/
public static float[] motion(Motion mode, long birthMs, float amplitude, float frequency) {
if (mode == Motion.STRAIGHT) return new float[]{0.0f, 0.0f};
double cycles = (birthMs * 0.001 * frequency) % 1.0;
float phase = (float) cycles * TAU;
float side = (float) Math.sin(phase) * amplitude;
float binormal = mode == Motion.HELIX ? (float) Math.cos(phase) * amplitude : 0.0f;
return new float[]{side, binormal};
}
}

View file

@ -0,0 +1,88 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
/**
* История одной ленты: точки с метками времени. Сдвиг меньше {@link #MIN_STEP} точкой не становится,
* точки старше срока жизни отбрасываются. Общая основа для ProjectileTrails и ElytraTrails.
*/
public final class TrackBuffer {
/** Минимальный сдвиг между соседними точками (блоки), чтобы лента не копила шум. */
public static final double MIN_STEP = 0.04;
private final ArrayDeque<RibbonPoint> points = new ArrayDeque<>();
private long lastSeen = Long.MIN_VALUE;
/**
* Фиксирует позицию, затем убирает устаревшие точки.
*
* @return true, если добавлена новая точка
*/
public boolean add(double x, double y, double z, long now, long lifeMs) {
lastSeen = now;
RibbonPoint last = points.peekLast();
boolean added = false;
if (last == null || (now != last.ms() && distance(last, x, y, z) >= MIN_STEP)) {
points.addLast(new RibbonPoint(x, y, z, now));
added = true;
}
trim(now, lifeMs);
return added;
}
/** Больше точек лента не держит: самые старые вытесняются. */
public static final int MAX_POINTS = 256;
/**
* Как {@link #add}, но для непрерывной ленты (концы крыльев): сдвиг меньше {@code minStep} не записывается,
* скачок дальше {@code maxStep} или пауза дольше {@code gapMs} (телепорт, перерыв в полёте) начинают ленту заново,
* у каждой точки сохраняется скорость источника.
*/
public boolean addContinuous(double x, double y, double z, long now, long lifeMs, float speed,
double minStep, double maxStep, long gapMs) {
RibbonPoint last = points.peekLast();
if (last != null && (now - lastSeen > gapMs || distance(last, x, y, z) > maxStep)) {
points.clear();
last = null;
}
lastSeen = now;
boolean added = false;
if (last == null || distance(last, x, y, z) >= minStep) {
points.addLast(new RibbonPoint(x, y, z, now, speed));
if (points.size() > MAX_POINTS) points.pollFirst();
added = true;
}
trim(now, lifeMs);
return added;
}
public void trim(long now, long lifeMs) {
while (!points.isEmpty() && now - points.peekFirst().ms() > lifeMs) points.pollFirst();
}
public boolean isEmpty() {
return points.isEmpty();
}
public int size() {
return points.size();
}
/** Время последнего вызова {@link #add}, даже если точка не добавилась. */
public long lastSeen() {
return lastSeen;
}
/** Копия точек от хвоста к голове. */
public List<RibbonPoint> points() {
return new ArrayList<>(points);
}
private static double distance(RibbonPoint a, double x, double y, double z) {
double dx = a.x() - x, dy = a.y() - y, dz = a.z() - z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
}

View file

@ -0,0 +1,291 @@
package dev.loki.lovisual.features.module.modules.visuals.effects.ribbon;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.phys.Vec3;
import java.util.Collection;
import java.util.List;
import java.util.function.IntBinaryOperator;
/**
* Лента, повёрнутая к камере: сужается и гаснет к хвосту, с необязательным контуром по краям.
* Общий рендер для ProjectileTrails и ElytraTrails.
*/
public final class TrailRibbon {
/** Как рисуется лента: ширина и прозрачность по точке, ориентация, мягкие края, ореол и колебания. */
public interface Style {
/** true: ширина лежит горизонтально (как лента-флаг); false: лента повёрнута к камере. */
boolean horizontal();
/** Края ленты тают в прозрачность вместо плоской полосы. */
boolean softEdges();
/** Масштаб ширины отдельного ореола вокруг ленты; 0 без ореола. */
float glowScale();
/** Полуширина в точке {@code i} из {@code n}. */
double halfWidth(float width, int i, int n, RibbonPoint p, float life);
/** Множитель альфы цвета в точке. */
float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance);
/** Смещение точки: {в сторону, по бинормали}; null без колебаний. */
default float[] motion(RibbonPoint p) {
return null;
}
}
/** Прежний вид (ProjectileTrails): лента к камере, сужение к хвосту, альфа как life в квадрате. */
public static final Style LEGACY = new Style() {
@Override
public boolean horizontal() {
return false;
}
@Override
public boolean softEdges() {
return false;
}
@Override
public float glowScale() {
return 0.0f;
}
@Override
public double halfWidth(float width, int i, int n, RibbonPoint p, float life) {
return TrailRibbon.halfWidth(width, life);
}
@Override
public float alpha(int i, int n, RibbonPoint p, float life, float cameraDistance) {
return life * life;
}
};
private TrailRibbon() {
}
/** Доля оставшейся жизни точки: 1 свежая, 0 истёкшая. */
public static float lifeFraction(long now, long ms, long lifeMs) {
if (lifeMs <= 0) return 0.0f;
float left = 1.0f - (now - ms) / (float) lifeMs;
return Math.max(0.0f, Math.min(1.0f, left));
}
/** Альфа точки падает как life в квадрате, RGB не меняется. */
public static int fadeAlpha(int argb, float life) {
float l = Math.max(0.0f, Math.min(1.0f, life));
int alpha = Math.round(((argb >>> 24) & 0xFF) * l * l);
return (alpha << 24) | (argb & 0x00FFFFFF);
}
/** Полуширина ленты: у свежей точки половина {@code width}, к хвосту сужается до четверти. */
public static double halfWidth(float width, float life) {
float l = Math.max(0.0f, Math.min(1.0f, life));
return width * 0.5 * (0.25 + 0.75 * l);
}
/** Лента прежнего вида. */
public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam,
IntBinaryOperator color) {
render(renderer, tracks, now, lifeMs, width, fill, glow, lines, lineWidth, depth, cam, color, LEGACY);
}
/**
* @param glow вся заливка складывается аддитивно (прежний «Glow»); ореол стиля рисуется отдельным слоем
* @param color цвет точки: (индекс от хвоста, число точек) -> ARGB
*/
public static void render(Renderer3D renderer, Collection<TrackBuffer> tracks, long now, long lifeMs, float width,
boolean fill, boolean glow, boolean lines, float lineWidth, boolean depth, Vec3 cam,
IntBinaryOperator color, Style style) {
var depthMode = depth ? Renderer3D.DepthMode.PRE_DEPTH : Renderer3D.DepthMode.MAIN;
MeshBuilder fillMesh = null;
if (fill) {
var pipeline = glow
? (depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH : LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE)
: (depth ? LoVisualRenderPipelines.WORLD_COLORED_LIQUID_IGNORE : LoVisualRenderPipelines.WORLD_COLORED);
fillMesh = renderer.batch(pipeline, depthMode);
}
MeshBuilder glowMesh = null;
if (fill && style.glowScale() > 0.0f) {
glowMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, depthMode);
}
MeshBuilder lineMesh = null;
if (lines) {
float prev = RenderState.lineWidth;
try {
RenderState.lineWidth = Math.max(0.5f, lineWidth);
lineMesh = renderer.batch(depth ? LoVisualRenderPipelines.WORLD_COLORED_LINES_LIQUID_IGNORE
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depthMode);
} finally {
RenderState.lineWidth = prev;
}
}
if (fillMesh == null && lineMesh == null && glowMesh == null) return;
for (TrackBuffer track : tracks) {
List<RibbonPoint> pts = track.points();
if (pts.size() >= 2) draw(pts, now, lifeMs, width, fillMesh, glowMesh, lineMesh, cam, color, style);
}
}
private static void draw(List<RibbonPoint> pts, long now, long lifeMs, float width, MeshBuilder fill,
MeshBuilder glowMesh, MeshBuilder line, Vec3 cam, IntBinaryOperator color, Style style) {
int n = pts.size();
double[][] center = new double[n][3];
for (int i = 0; i < n; i++) {
RibbonPoint p = pts.get(i);
center[i] = new double[]{p.x(), p.y(), p.z()};
}
double[][] side = frame(center, cam, style.horizontal());
if (style.motion(pts.get(0)) != null) {
double[][] tangent = tangents(center);
for (int i = 0; i < n; i++) {
float[] m = style.motion(pts.get(i));
if (m == null) continue;
double bx = side[i][1] * tangent[i][2] - side[i][2] * tangent[i][1];
double by = side[i][2] * tangent[i][0] - side[i][0] * tangent[i][2];
double bz = side[i][0] * tangent[i][1] - side[i][1] * tangent[i][0];
center[i][0] += side[i][0] * m[0] + bx * m[1];
center[i][1] += side[i][1] * m[0] + by * m[1];
center[i][2] += side[i][2] * m[0] + bz * m[1];
}
side = frame(center, cam, style.horizontal());
}
double[][] left = new double[n][3];
double[][] right = new double[n][3];
int[] argb = new int[n];
double[] half = new double[n];
for (int i = 0; i < n; i++) {
RibbonPoint p = pts.get(i);
float life = style == LEGACY ? lifeFraction(now, p.ms(), lifeMs) : RibbonShape.life(now, p.ms(), lifeMs);
half[i] = style.halfWidth(width, i, n, p, life);
double dx = center[i][0] - cam.x, dy = center[i][1] - cam.y, dz = center[i][2] - cam.z;
float mult = style.alpha(i, n, p, life, (float) Math.sqrt(dx * dx + dy * dy + dz * dz));
argb[i] = scaleAlpha(color.applyAsInt(i, n), mult);
for (int k = 0; k < 3; k++) {
left[i][k] = center[i][k] + side[i][k] * half[i];
right[i][k] = center[i][k] - side[i][k] * half[i];
}
}
boolean soft = style.softEdges();
for (int i = 1; i < n; i++) {
if (fill != null) {
if (soft) softQuad(fill, left[i - 1], center[i - 1], right[i - 1], left[i], center[i], right[i],
argb[i - 1], argb[i]);
else quad(fill, left[i - 1], right[i - 1], right[i], left[i], argb[i - 1], argb[i]);
}
if (glowMesh != null) {
float g = style.glowScale();
double[] l0 = offset(center[i - 1], side[i - 1], half[i - 1] * g);
double[] r0 = offset(center[i - 1], side[i - 1], -half[i - 1] * g);
double[] l1 = offset(center[i], side[i], half[i] * g);
double[] r1 = offset(center[i], side[i], -half[i] * g);
softQuad(glowMesh, l0, center[i - 1], r0, l1, center[i], r1,
scaleAlpha(argb[i - 1], GLOW_ALPHA), scaleAlpha(argb[i], GLOW_ALPHA));
}
if (line != null) {
segment(line, left[i - 1], left[i], argb[i - 1], argb[i]);
segment(line, right[i - 1], right[i], argb[i - 1], argb[i]);
}
}
}
/** Относительная яркость ореола. */
static final float GLOW_ALPHA = 0.35f;
private static double[] offset(double[] c, double[] side, double d) {
return new double[]{c[0] + side[0] * d, c[1] + side[1] * d, c[2] + side[2] * d};
}
/** Единичные касательные по центральным разностям соседей. */
private static double[][] tangents(double[][] c) {
int n = c.length;
double[][] out = new double[n][3];
for (int i = 0; i < n; i++) {
double[] a = c[Math.max(0, i - 1)], b = c[Math.min(n - 1, i + 1)];
double dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-6) {
out[i] = new double[]{0.0, 0.0, 1.0};
} else {
out[i] = new double[]{dx / len, dy / len, dz / len};
}
}
return out;
}
/**
* Единичный вектор «в сторону» для каждой точки: к камере (касательная x взгляд) или горизонтальный
* ({@code (-tz, 0, tx)}), с запасным вариантом для вертикального пути.
*/
static double[][] frame(double[][] c, Vec3 cam, boolean horizontal) {
int n = c.length;
double[][] t = tangents(c);
double[][] out = new double[n][3];
for (int i = 0; i < n; i++) {
double sx, sy, sz;
if (horizontal) {
sx = -t[i][2];
sy = 0.0;
sz = t[i][0];
if (sx * sx + sz * sz < 1.0E-7) {
sx = 1.0;
sz = 0.0;
}
} else {
double vx = cam.x - c[i][0], vy = cam.y - c[i][1], vz = cam.z - c[i][2];
sx = t[i][1] * vz - t[i][2] * vy;
sy = t[i][2] * vx - t[i][0] * vz;
sz = t[i][0] * vy - t[i][1] * vx;
}
double len = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (len < 1e-6) len = 1.0;
out[i] = new double[]{sx / len, sy / len, sz / len};
}
return out;
}
/** Альфа цвета, умноженная на множитель 0..1; RGB не меняется. */
static int scaleAlpha(int argb, float mult) {
float m = mult < 0.0f ? 0.0f : Math.min(mult, 1.0f);
int alpha = Math.round(((argb >>> 24) & 0xFF) * m);
return (alpha << 24) | (argb & 0x00FFFFFF);
}
private static void quad(MeshBuilder m, double[] l0, double[] r0, double[] r1, double[] l1, int c0, int c1) {
quad4(m, l0, r0, r1, l1, c0, c0, c1, c1);
}
private static void quad4(MeshBuilder m, double[] p0, double[] p1, double[] p2, double[] p3,
int c0, int c1, int c2, int c3) {
m.ensureQuadCapacity();
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next();
int c = m.vec3(p2[0], p2[1], p2[2]).color(new RenderColor(c2)).next();
int d = m.vec3(p3[0], p3[1], p3[2]).color(new RenderColor(c3)).next();
m.quad(a, b, c, d);
}
/** Две полосы: от прозрачного левого края к непрозрачной оси и от оси к прозрачному правому краю. */
private static void softQuad(MeshBuilder m, double[] l0, double[] k0, double[] r0,
double[] l1, double[] k1, double[] r1, int c0, int c1) {
int e0 = scaleAlpha(c0, 0.0f), e1 = scaleAlpha(c1, 0.0f);
quad4(m, l0, k0, k1, l1, e0, c0, c1, e1);
quad4(m, k0, r0, r1, k1, c0, e0, e1, c1);
}
private static void segment(MeshBuilder m, double[] p0, double[] p1, int c0, int c1) {
m.ensureLineCapacity();
int a = m.vec3(p0[0], p0[1], p0[2]).color(new RenderColor(c0)).next();
int b = m.vec3(p1[0], p1[1], p1[2]).color(new RenderColor(c1)).next();
m.line(a, b);
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
import dev.loki.lovisual.config.values.color.RGBColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.relations.category.CategoryService;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.util.Util;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Colours worn armor by its remaining durability (full, warning, broken). Which entities qualify is
* decided once per frame at render-state extraction; the tint itself is applied in EquipmentLayerRendererMixin.
*/
@ModuleInfo(
id = "armorcolor",
displayName = "Armor Color",
aliases = {"armorcolor"},
category = ModuleCategory.VISUALS
)
public class ArmorColor extends Module {
private static final String PLAYERS = "players";
private static final String FRIENDS = "friends";
private static final String SELF = "self";
private static final String MOBS = "mobs";
private final BooleanMapValue targets = group("armorColorTargets", "targets", defaultTargets());
private final ModeValue colorMode =
modeSetting("armorColorMode", "color_mode", ArmorTint.GRADIENT, ArmorTint.GRADIENT, ArmorTint.STEPS);
private final RGBColorValue fullColor = colorNoAlpha("armorColorFull", "full_color", "#4CD964");
private final RGBColorValue warnColor = colorNoAlpha("armorColorWarn", "warn_color", "#FFC107");
private final RGBColorValue brokenColor = colorNoAlpha("armorColorBroken", "broken_color", "#FF3B30");
private final NumberValue<Float> strength = num("armorColorStrength", "strength", 1.0f, 0.1f, 1.0f);
private final NumberValue<Float> onlyBelow = num("armorColorOnlyBelow", "only_below", 100.0f, 5.0f, 100.0f);
private final BooleanValue pulse = bool("armorColorPulse", "pulse", true);
private static Map<String, Boolean> defaultTargets() {
Map<String, Boolean> map = new LinkedHashMap<>();
map.put(PLAYERS, true);
map.put(FRIENDS, true);
map.put(SELF, true);
map.put(MOBS, false);
return map;
}
/** Whether this entity's armor should be coloured; evaluated at render-state extraction. */
public boolean covers(LivingEntity entity) {
if (!isEnabled()) return false;
if (entity instanceof LocalPlayer) return targets.get(SELF);
if (entity instanceof Player player) {
return targets.get(CategoryService.isFriend(player) ? FRIENDS : PLAYERS);
}
return targets.get(MOBS);
}
/** @return the tint for one armor piece, {@link ArmorTint#NO_TINT} to keep vanilla colour. */
public int tintFor(ItemStack stack, Object renderState) {
if (!isEnabled() || stack == null || stack.isEmpty() || !stack.isDamageableItem()) return ArmorTint.NO_TINT;
if (!(renderState instanceof ArmorColorState state) || !state.lovisual$isArmorColored()) return ArmorTint.NO_TINT;
float fraction = ArmorTint.fraction(stack.getDamageValue(), stack.getMaxDamage());
if (fraction < 0.0f || fraction * 100.0f > onlyBelow.get()) return ArmorTint.NO_TINT;
int rgb = ArmorTint.ramp(fraction, colorMode.get(),
fullColor.getArgb() & 0xFFFFFF, warnColor.getArgb() & 0xFFFFFF, brokenColor.getArgb() & 0xFFFFFF);
if (pulse.get() && fraction <= ArmorTint.CRITICAL_FRACTION) rgb = ArmorTint.pulse(rgb, Util.getMillis());
return ArmorTint.tint(rgb, strength.get());
}
}

View file

@ -0,0 +1,8 @@
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
/** Implemented by LivingEntityRenderState: whether ArmorColor applies to this entity (decided at extraction). */
public interface ArmorColorState {
boolean lovisual$isArmorColored();
void lovisual$setArmorColored(boolean colored);
}

View file

@ -0,0 +1,67 @@
package dev.loki.lovisual.features.module.modules.visuals.items.armor;
import net.minecraft.util.Mth;
/** Pure maths for durability-based armor colouring (RGB/ARGB ints). */
public final class ArmorTint {
public static final String GRADIENT = "Gradient";
public static final String STEPS = "Steps";
/** Returned when the armor must keep its own colour. */
public static final int NO_TINT = 0;
static final float WARN_FRACTION = 0.5f;
static final float CRITICAL_FRACTION = 0.25f;
private static final double PULSE_PERIOD_MS = 190.0;
private static final float PULSE_DEPTH = 0.4f;
private static final int WHITE = 0xFFFFFF;
private ArmorTint() {
}
/** Remaining durability 0..1, or -1 for an item without durability. */
public static float fraction(int damage, int maxDamage) {
if (maxDamage <= 0) return -1.0f;
return Mth.clamp(1.0f - (float) damage / maxDamage, 0.0f, 1.0f);
}
/** Full above half, warning down to a quarter, broken below; Gradient blends, Steps snaps. */
public static int ramp(float fraction, String mode, int full, int warn, int broken) {
float f = Mth.clamp(fraction, 0.0f, 1.0f);
if (STEPS.equals(mode)) {
if (f > WARN_FRACTION) return full;
return f > CRITICAL_FRACTION ? warn : broken;
}
return f > WARN_FRACTION
? lerpRgb(warn, full, (f - WARN_FRACTION) / WARN_FRACTION)
: lerpRgb(broken, warn, f / WARN_FRACTION);
}
/** Darkens the colour rhythmically, used once the armor is nearly broken. */
public static int pulse(int rgb, long millis) {
float wave = (float) ((Math.sin(millis / PULSE_PERIOD_MS) + 1.0) * 0.5);
float k = 1.0f - PULSE_DEPTH * wave;
return Math.round((rgb >> 16 & 0xFF) * k) << 16 | Math.round((rgb >> 8 & 0xFF) * k) << 8 | Math.round((rgb & 0xFF) * k);
}
/** Final armor tint: white blended towards {@code rgb} by {@code strength}, opaque. */
public static int tint(int rgb, float strength) {
return 0xFF000000 | lerpRgb(WHITE, rgb, strength);
}
/** Multiplies the layer's own colour (dye) by the tint; {@link #NO_TINT} leaves it alone. */
public static int apply(int layerColor, int tint) {
if (tint == NO_TINT) return layerColor;
int r = (layerColor >> 16 & 0xFF) * (tint >> 16 & 0xFF) / 255;
int g = (layerColor >> 8 & 0xFF) * (tint >> 8 & 0xFF) / 255;
int b = (layerColor & 0xFF) * (tint & 0xFF) / 255;
return (layerColor & 0xFF000000) | r << 16 | g << 8 | b;
}
static int lerpRgb(int from, int to, float t) {
float k = Mth.clamp(t, 0.0f, 1.0f);
int r = Math.round(Mth.lerp(k, from >> 16 & 0xFF, to >> 16 & 0xFF));
int g = Math.round(Mth.lerp(k, from >> 8 & 0xFF, to >> 8 & 0xFF));
int b = Math.round(Mth.lerp(k, from & 0xFF, to & 0xFF));
return r << 16 | g << 8 | b;
}
}

View file

@ -0,0 +1,64 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
import dev.loki.lovisual.config.values.color.RGBColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
/**
* Enchantment glint on items and armor: style (Rainbow, Solid, Gradient, Hidden), colours, strength, scroll
* speed and stripe scale. Speed and scale go through GlintTextureMixin, the look by repainting the textures.
*/
@ModuleInfo(
id = "enchantglint",
displayName = "Enchant Glint",
aliases = {"enchantglint", "glint"},
category = ModuleCategory.VISUALS
)
public class EnchantGlint extends Module {
private final ModeValue style = modeSetting("enchantGlintStyle", "style", GlintTint.RAINBOW,
GlintTint.RAINBOW, GlintTint.SOLID, GlintTint.GRADIENT, GlintTint.HIDDEN);
private final RGBColorValue color =
visibleWhen(colorNoAlpha("enchantGlintColor", "color", "#FF4FD8"), this::usesPrimary);
private final RGBColorValue secondColor =
visibleWhen(colorNoAlpha("enchantGlintColor2", "color2", "#4F9BFF"), this::usesSecondary);
private final NumberValue<Float> strength = num("enchantGlintStrength", "strength", 1.5f, 0.2f, 3.0f);
private final NumberValue<Float> speed = num("enchantGlintSpeed", "speed", 1.0f, 0.1f, 4.0f);
private final NumberValue<Float> scale = num("enchantGlintScale", "scale", 1.0f, 0.2f, 4.0f);
private final BooleanValue items = bool("enchantGlintItems", "items", true);
private final BooleanValue armor = bool("enchantGlintArmor", "armor", true);
private final GlintTexture texture = new GlintTexture();
private boolean usesPrimary() {
return GlintTint.SOLID.equals(style.get()) || GlintTint.GRADIENT.equals(style.get());
}
private boolean usesSecondary() {
return GlintTint.GRADIENT.equals(style.get());
}
@Override
public void onTick() {
GlintTexture.Paint paint = new GlintTexture.Paint(style.get(),
color.getArgb() & 0xFFFFFF, secondColor.getArgb() & 0xFFFFFF, strength.get());
texture.sync(items.get() ? paint : null, armor.get() ? paint : null);
}
@Override
public void onDisable() {
texture.sync(null, null);
texture.forget();
}
public float speedFactor() {
return isEnabled() ? speed.get() : 1.0f;
}
public float scaleFactor() {
return isEnabled() ? scale.get() : 1.0f;
}
}

View file

@ -0,0 +1,72 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
import com.mojang.blaze3d.platform.NativeImage;
import com.mojang.blaze3d.systems.RenderSystem;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.feature.ItemFeatureRenderer;
import net.minecraft.client.renderer.texture.AbstractTexture;
import net.minecraft.client.renderer.texture.ReloadableTexture;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Rewrites the two vanilla glint textures in place with a restyled copy of the pack's original image.
* Remembers which texture object it painted so a resource reload (which re-uploads the originals) is detected.
*/
final class GlintTexture {
/** What to paint onto a glint texture; a null paint means "leave it vanilla". */
record Paint(String style, int primary, int secondary, float strength) {
}
private final Map<AbstractTexture, Paint> painted = new IdentityHashMap<>();
/** Applies {@code items} to the item/entity glint and {@code armor} to the armor glint. */
void sync(Paint items, Paint armor) {
sync(ItemFeatureRenderer.ENCHANTED_GLINT_ITEM, items);
sync(ItemFeatureRenderer.ENCHANTED_GLINT_ARMOR, armor);
}
void forget() {
painted.clear();
}
private void sync(Identifier id, Paint paint) {
Minecraft mc = Minecraft.getInstance();
AbstractTexture texture = mc.getTextureManager().getTexture(id);
if (!(texture instanceof ReloadableTexture reloadable) || texture.getTexture() == null) return;
if (java.util.Objects.equals(painted.get(texture), paint)) return;
if (upload(mc, reloadable, paint)) {
if (paint == null) painted.remove(texture);
else painted.put(texture, paint);
}
}
private static boolean upload(Minecraft mc, ReloadableTexture texture, Paint paint) {
Optional<Resource> resource = mc.getResourceManager().getResource(texture.resourceId());
if (resource.isEmpty()) return false;
try (InputStream in = resource.get().open(); NativeImage image = NativeImage.read(in)) {
if (paint != null) repaint(image, paint);
RenderSystem.getDevice().createCommandEncoder().writeToTexture(texture.getTexture(), image);
return true;
} catch (IOException e) {
return false;
}
}
private static void repaint(NativeImage image, Paint paint) {
int width = image.getWidth();
int height = image.getHeight();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
image.setPixel(x, y, GlintTint.pixel(image.getPixel(x, y), (x + 0.5f) / width, (y + 0.5f) / height,
paint.style(), paint.primary(), paint.secondary(), paint.strength()));
}
}
}
}

View file

@ -0,0 +1,43 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
import net.minecraft.util.Mth;
/** Pure pixel maths for repainting the enchantment glint texture (ARGB ints, u and v in 0..1). */
public final class GlintTint {
public static final String SOLID = "Solid";
public static final String GRADIENT = "Gradient";
public static final String RAINBOW = "Rainbow";
public static final String HIDDEN = "Hidden";
private static final float RAINBOW_SKEW = 0.25f;
private static final float GRADIENT_SKEW = 0.25f;
private GlintTint() {
}
/** Colours the glint streaks in a style; the pixel's strongest channel keeps the streak shape. */
public static int pixel(int argb, float u, float v, String style, int primary, int secondary, float strength) {
if (HIDDEN.equals(style)) return argb & 0x00FFFFFF;
float level = Math.min(1.0f, (Math.max(argb >> 16 & 0xFF, Math.max(argb >> 8 & 0xFF, argb & 0xFF)) / 255.0f) * strength);
int color = switch (style) {
case RAINBOW -> Mth.hsvToRgb(frac(u * (1.0f - RAINBOW_SKEW) + v * RAINBOW_SKEW), 1.0f, 1.0f);
case GRADIENT -> lerp(primary, secondary, frac(u * (1.0f - GRADIENT_SKEW) + v * GRADIENT_SKEW));
default -> primary;
};
int r = Math.round((color >> 16 & 0xFF) * level);
int g = Math.round((color >> 8 & 0xFF) * level);
int b = Math.round((color & 0xFF) * level);
return (argb & 0xFF000000) | r << 16 | g << 8 | b;
}
static int lerp(int from, int to, float t) {
int r = Math.round(Mth.lerp(t, from >> 16 & 0xFF, to >> 16 & 0xFF));
int g = Math.round(Mth.lerp(t, from >> 8 & 0xFF, to >> 8 & 0xFF));
int b = Math.round(Mth.lerp(t, from & 0xFF, to & 0xFF));
return r << 16 | g << 8 | b;
}
private static float frac(float x) {
return x - (float) Math.floor(x);
}
}

View file

@ -5,6 +5,7 @@ import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.HandScripts;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.util.animation.EaseCurves;
@ -55,6 +56,28 @@ public final class HoldMyItems extends Module {
public final NumberValue<Float> gripTurn = visibleWhen(num("grip_turn", 0.0f, -90.0f, 90.0f), gripEnabled::get);
public final NumberValue<Float> gripRoll = visibleWhen(num("grip_roll", 0.0f, -180.0f, 180.0f), gripEnabled::get);
/** Принудительно рисовать обе руки (как AlwaysVisibleHand), если включён режим со скриптами. */
public final BooleanValue alwaysHands = visibleWhen(bool("always_hands", false), this::scriptsActive);
public boolean forcesBothHands() {
return isEnabled() && scriptsActive() && alwaysHands.get();
}
/** Скрипты, если они уже создавались (для миксинов камеры и свинга), без ленивой загрузки LuaJ. */
public HandScripts scriptsOrNull() {
return isEnabled() && scriptsActive() ? scripts : null;
}
@Override
public void onTick() {
if (scripts != null && scriptsActive()) scripts.particles().tick();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depth, float partialTicks) {
if (scripts != null && isEnabled() && scriptsActive()) scripts.particles().render(renderer, partialTicks);
}
public boolean dynamicsActive() {
return !"Scripts".equals(handMode.get());
}

View file

@ -1,14 +1,17 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.Easings;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.EntityApi;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.PoseApi;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.HandParticles;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.ModelAnimator;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.VanillaApi;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptFiles;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.ScriptSlot;
import net.fabricmc.loader.api.FabricLoader;
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
@ -22,30 +25,24 @@ import org.luaj.vm2.LuaValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Скрипты руки: {@code hand_pose.lua} (поза всей руки) и {@code item_pose.lua} (поза предмета).
* Источник: {@code config/lovisual/holdmyitems/<name>.lua} (горячая перезагрузка по mtime раз в
* секунду), иначе встроенный пример из ресурсов мода. Ошибка скрипта пишется в лог один раз и
* отключает только этот скрипт до правки файла.
* Скрипты руки (см. {@link ScriptKind}): hand_pose, hand_relative_pose, item_pose, item_model и их аддоны.
* Порядок за кадр руки: hand_pose, hand_relative_pose, (ваниль), item_pose, item_model. Источник и горячая
* перезагрузка по mtime раз в секунду: {@link ScriptFiles}. Ошибка скрипта пишется в лог один раз и отключает
* только этот скрипт до правки файла.
*/
public final class HandScripts {
public static final String HAND_POSE = "hand_pose";
public static final String ITEM_POSE = "item_pose";
private static final Logger LOG = LoggerFactory.getLogger(HandScripts.class);
private static final long RECHECK_MS = 1000L;
private final LuaSandbox sandbox = new LuaSandbox();
private final PoseApi pose = new PoseApi();
private final long[] stamps = {Long.MIN_VALUE, Long.MIN_VALUE};
private final ScriptSlot[] slots = new ScriptSlot[2];
private final VanillaApi vanilla = new VanillaApi();
private final HandParticles particles = new HandParticles(sandbox, pose);
private final ScriptKind[] kinds = ScriptKind.values();
private final ScriptSlot[][] slots = new ScriptSlot[kinds.length][2];
private final long[][] stamps = new long[kinds.length][2];
private final float[][] lastState = new float[2][2];
private long lastCheck;
private Player player;
@ -56,66 +53,82 @@ public final class HandScripts {
g.set("M", pose.table());
g.set("P", EntityApi.player(() -> player));
g.set("I", EntityApi.item(() -> stack));
g.set("V", vanilla.vanillaTable());
g.set("C", vanilla.cameraTable());
g.set("animator", ModelAnimator.INSTANCE.table());
g.set("particleManager", particles.table());
LuaTable easings = new LuaTable();
Easings.all().forEach((name, f) -> LuaTables.put(easings, name, a -> LuaValue.valueOf(f.applyAsDouble(LuaTables.num(a, 2, 0)))));
g.set("Easings", easings);
reload();
}
/** Применяет hand_pose к матрице руки; true, если был pushPose и нужен popPose. */
public VanillaApi vanilla() {
return vanilla;
}
public HandParticles particles() {
return particles;
}
/** Принудительная перезагрузка всех скриптов на ближайшем кадре. */
public final void reload() {
for (long[] row : stamps) java.util.Arrays.fill(row, Long.MIN_VALUE);
lastCheck = 0L;
particles.clear();
}
/** Применяет hand_pose и hand_relative_pose к матрице руки; true, если был pushPose и нужен popPose. */
public static boolean applyHandPose(HoldMyItems module, AbstractClientPlayer player, InteractionHand hand,
float swing, float equip, float tickDelta, ItemStack item, PoseStack ms) {
if (!module.scriptsActive()) return false;
boolean mainHand = hand == InteractionHand.MAIN_HAND;
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
ms.pushPose();
module.scripts().runHand(new HandFrame(player, item, mainHand, rightArm, swing, equip, tickDelta, ms));
HandScripts s = module.scripts();
HandFrame f = new HandFrame(player, item, mainHand, rightArm, swing, equip, tickDelta, ms);
s.vanilla.begin(mainHand);
s.run(ScriptKind.HAND, f);
s.run(ScriptKind.RELATIVE, f);
s.lastState[mainHand ? 0 : 1][0] = swing;
s.lastState[mainHand ? 0 : 1][1] = equip;
return true;
}
/** Применяет item_pose к матрице предмета; true, если был pushPose и нужен popPose. */
/** Применяет item_pose и item_model; true, если был pushPose и нужен popPose. */
public static boolean applyItemPose(HoldMyItems module, LivingEntity entity, ItemStack stack,
ItemDisplayContext ctx, PoseStack ms) {
if (!ctx.firstPerson() || !(entity instanceof AbstractClientPlayer player)) return false;
if (!module.isEnabled() || !module.scriptsActive()) return false;
boolean rightArm = ctx == ItemDisplayContext.FIRST_PERSON_RIGHT_HAND;
boolean mainHand = (player.getMainArm() == HumanoidArm.RIGHT) == rightArm;
HandScripts scripts = module.scripts();
float[] last = scripts.lastState(mainHand);
HandScripts s = module.scripts();
float[] last = s.lastState[mainHand ? 0 : 1];
HandFrame f = new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms);
ms.pushPose();
scripts.runItem(new HandFrame(player, stack, mainHand, rightArm, last[0], last[1], 0.0f, ms));
s.run(ScriptKind.ITEM, f);
ModelAnimator.INSTANCE.clear();
s.sandbox.globals().set("quadCount", LuaValue.valueOf(ModelAnimator.INSTANCE.lastQuadCount()));
s.run(ScriptKind.MODEL, f);
return true;
}
public static Path scriptDir() {
return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("holdmyitems");
}
/** Принудительная перезагрузка на следующем кадре. */
public void reload() {
stamps[0] = Long.MIN_VALUE;
stamps[1] = Long.MIN_VALUE;
lastCheck = 0L;
}
public void runHand(HandFrame f) {
run(0, HAND_POSE, f);
lastState[f.mainHand() ? 0 : 1][0] = f.swingProgress();
lastState[f.mainHand() ? 0 : 1][1] = f.equipProgress();
}
public void runItem(HandFrame f) {
run(1, ITEM_POSE, f);
}
/** Последний известный swing/equip руки: item_pose рендерится вне submitArmWithItem. */
public float[] lastState(boolean mainHand) {
return lastState[mainHand ? 0 : 1];
}
private void run(int index, String name, HandFrame f) {
private void run(ScriptKind kind, HandFrame f) {
refresh();
ScriptSlot slot = slots[index];
if (slot == null || !slot.canRun()) return;
for (int part = 0; part < 2; part++) {
ScriptSlot slot = slots[kind.ordinal()][part];
if (slot == null || !slot.canRun()) continue;
bind(slot, f);
try {
slot.run();
} finally {
pose.finish();
}
if (!slot.canRun()) LOG.warn("HoldMyItems script {} disabled: {}", kind.file(part), slot.error());
}
}
private void bind(ScriptSlot slot, HandFrame f) {
this.player = f.player();
this.stack = f.stack();
Globals g = sandbox.globals();
@ -129,47 +142,23 @@ public final class HandScripts {
g.set("useDuration", LuaValue.valueOf(f.player().getTicksUsingItem()));
g.set("time", LuaValue.valueOf(System.nanoTime() / 1.0e9));
pose.bind(f.pose());
try {
slot.run();
} finally {
pose.finish();
}
if (!slot.canRun()) LOG.warn("HoldMyItems script {} disabled: {}", name, slot.error());
}
private void refresh() {
long now = System.currentTimeMillis();
if (now - lastCheck < RECHECK_MS) return;
lastCheck = now;
String[] names = {HAND_POSE, ITEM_POSE};
for (int i = 0; i < names.length; i++) {
Path file = scriptDir().resolve(names[i] + ".lua");
long stamp = stampOf(file);
if (stamp == stamps[i]) continue;
stamps[i] = stamp;
String source = read(file, names[i]);
slots[i] = source == null ? null : new ScriptSlot(names[i], source, sandbox);
if (slots[i] != null && !slots[i].canRun()) LOG.warn("HoldMyItems script {}: {}", names[i], slots[i].error());
}
}
private static long stampOf(Path file) {
try {
return Files.isRegularFile(file) ? Files.getLastModifiedTime(file).toMillis() : -1L;
} catch (IOException e) {
return -1L;
}
}
private static String read(Path file, String name) {
try {
if (Files.isRegularFile(file)) return Files.readString(file, StandardCharsets.UTF_8);
try (InputStream in = HandScripts.class.getResourceAsStream("/assets/lovisual/holdmyitems/" + name + ".lua")) {
return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8);
for (ScriptKind kind : kinds) {
for (int part = 0; part < 2; part++) {
String name = kind.file(part);
long stamp = ScriptFiles.stamp(name, part == 0);
if (stamp == stamps[kind.ordinal()][part]) continue;
stamps[kind.ordinal()][part] = stamp;
String source = stamp < 0 ? null : ScriptFiles.read(name);
ScriptSlot slot = source == null ? null : new ScriptSlot(name, source, sandbox);
slots[kind.ordinal()][part] = slot;
if (slot != null && !slot.canRun()) LOG.warn("HoldMyItems script {}: {}", name, slot.error());
}
} catch (IOException e) {
LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage());
return null;
}
}
}

View file

@ -0,0 +1,21 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua;
/** Виды скриптов HoldMyItems: основной файл и необязательный аддон, который выполняется сразу после него. */
public enum ScriptKind {
HAND("hand_pose", "hand_addon"),
RELATIVE("hand_relative_pose", "hand_relative_addon"),
ITEM("item_pose", "item_addon"),
MODEL("item_model", "item_model_addon");
public final String main;
public final String addon;
ScriptKind(String main, String addon) {
this.main = main;
this.addon = addon;
}
public String file(int part) {
return part == 0 ? main : addon;
}
}

View file

@ -21,6 +21,11 @@ public final class PoseApi {
return this;
}
/** Текущая матрица позы (копия) либо null, если скрипт не выполняется. */
public org.joml.Matrix4f matrix() {
return stack == null ? null : new org.joml.Matrix4f(stack.last().pose());
}
/** Закрывает push, оставленные скриптом незакрытыми. */
public void finish() {
while (stack != null && depth > 0) {

View file

@ -0,0 +1,149 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.PoseApi;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime.LuaSandbox;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Minecraft;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.luaj.vm2.LuaError;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.addBillboardVertices;
import static dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspRender.applyOpacity;
/**
* Lua-глобал {@code particleManager}: {@code particleManager:addParticle{ x,y,z, vx,vy,vz, lifetime, size, color,
* alpha, gravity, drag, tick = function(p) ... end }}. Координаты x,y,z заданы в системе текущей позы руки
* (после M:), частица живёт в мире. Колбэк tick получает таблицу p и может менять x,y,z,vx,vy,vz,size,alpha,color.
*/
public final class HandParticles {
private static final Logger LOG = LoggerFactory.getLogger(HandParticles.class);
private static final int MAX = 256;
private static final class P {
float x, y, z, px, py, pz, vx, vy, vz, size, alpha, gravity, drag;
int age, life, color;
LuaValue tick;
}
private final List<P> list = new ArrayList<>();
private final LuaSandbox sandbox;
private final PoseApi pose;
public HandParticles(LuaSandbox sandbox, PoseApi pose) {
this.sandbox = sandbox;
this.pose = pose;
}
public void clear() {
list.clear();
}
public LuaTable table() {
LuaTable t = new LuaTable();
LuaTables.put(t, "addParticle", a -> {
if (list.size() < MAX && a.arg(2).istable()) spawn(a.arg(2));
return LuaValue.NIL;
});
return t;
}
private void spawn(LuaValue o) {
Matrix4f m = pose.matrix();
Minecraft mc = Minecraft.getInstance();
if (m == null || mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return;
Vector3f local = m.transformPosition(num(o, "x", 0), num(o, "y", 0), num(o, "z", 0), new Vector3f());
Vector3f world = new Quaternionf(RenderState.cameraRotation).transform(local);
Vec3 cam = mc.gameRenderer.mainCamera().position();
P p = new P();
p.x = p.px = (float) cam.x + world.x;
p.y = p.py = (float) cam.y + world.y;
p.z = p.pz = (float) cam.z + world.z;
Vector3f v = new Quaternionf(RenderState.cameraRotation).transform(new Vector3f(num(o, "vx", 0), num(o, "vy", 0), num(o, "vz", 0)));
p.vx = v.x;
p.vy = v.y;
p.vz = v.z;
p.life = Math.max(1, Math.min(200, (int) num(o, "lifetime", 20)));
p.size = num(o, "size", 0.05f);
p.alpha = num(o, "alpha", 1f);
p.gravity = num(o, "gravity", 0f);
p.drag = num(o, "drag", 0.98f);
LuaValue c = o.get("color");
p.color = c.isnil() ? 0xFFFFFFFF : (int) (long) c.todouble();
LuaValue tick = o.get("tick");
p.tick = tick.isfunction() ? tick : null;
list.add(p);
}
private static float num(LuaValue o, String key, float def) {
LuaValue v = o.get(key);
return v.isnumber() ? (float) v.todouble() : def;
}
public void tick() {
for (int i = list.size() - 1; i >= 0; i--) {
P p = list.get(i);
p.px = p.x;
p.py = p.y;
p.pz = p.z;
p.vy -= p.gravity;
p.vx *= p.drag;
p.vy *= p.drag;
p.vz *= p.drag;
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
if (p.tick != null) callTick(p);
if (++p.age >= p.life) list.remove(i);
}
}
private void callTick(P p) {
LuaTable t = new LuaTable();
t.set("x", p.x); t.set("y", p.y); t.set("z", p.z);
t.set("vx", p.vx); t.set("vy", p.vy); t.set("vz", p.vz);
t.set("size", p.size); t.set("alpha", p.alpha); t.set("color", (double) (p.color & 0xFFFFFFFFL));
t.set("age", p.age); t.set("life", p.life);
sandbox.beginRun();
try {
p.tick.call(t);
p.x = num(t, "x", p.x); p.y = num(t, "y", p.y); p.z = num(t, "z", p.z);
p.vx = num(t, "vx", p.vx); p.vy = num(t, "vy", p.vy); p.vz = num(t, "vz", p.vz);
p.size = num(t, "size", p.size); p.alpha = num(t, "alpha", p.alpha);
p.color = (int) (long) t.get("color").todouble();
} catch (LuaError e) {
LOG.warn("HoldMyItems particle tick failed: {}", e.getMessage());
p.tick = null;
}
}
public void render(Renderer3D renderer, float partialTicks) {
if (renderer == null || list.isEmpty()) return;
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE,
TextureStorage.BLOOM, Renderer3D.DepthMode.NONE);
if (mesh == null) return;
for (P p : list) {
float fade = 1.0f - (p.age + partialTicks) / p.life;
int color = applyOpacity(p.color, p.alpha * Math.max(0f, fade));
Vector3f right = new Vector3f(1, 0, 0).rotate(RenderState.cameraRotation).mul(p.size);
Vector3f up = new Vector3f(0, 1, 0).rotate(RenderState.cameraRotation).mul(p.size);
addBillboardVertices(mesh, p.px + (p.x - p.px) * partialTicks, p.py + (p.y - p.py) * partialTicks,
p.pz + (p.z - p.pz) * partialTicks, right, up, color, color, color, color);
}
}
}

View file

@ -0,0 +1,96 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Op;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext.QuadOps.Type;
import net.minecraft.client.resources.model.geometry.BakedQuad;
import org.joml.Vector3f;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
import org.luaj.vm2.Varargs;
import java.util.ArrayList;
import java.util.List;
/**
* Lua-глобал {@code animator} для item_model.lua: анимация частей (квадов) модели предмета.
* {@code animator:rotateX(from, to, deg, px, py, pz)}, {@code moveX/Y/Z(from, to, px)}, {@code scale(from, to, k)}
* по диапазону индексов квадов; pivot и сдвиги в пикселях. Операции действуют на один рендер предмета.
*/
public final class ModelAnimator {
public static final ModelAnimator INSTANCE = new ModelAnimator();
private static final float PX = 1.0f / 16.0f;
private final List<Op> ops = new ArrayList<>();
private int lastQuadCount;
private ModelAnimator() {
}
public void clear() {
ops.clear();
}
/** Число квадов последнего отрендеренного слоя (скрипту отдаётся глобалом quadCount). */
public int lastQuadCount() {
return lastQuadCount;
}
public LuaTable table() {
LuaTable t = new LuaTable();
LuaTables.put(t, "rotateX", a -> rotate(Type.ROT_X, a));
LuaTables.put(t, "rotateY", a -> rotate(Type.ROT_Y, a));
LuaTables.put(t, "rotateZ", a -> rotate(Type.ROT_Z, a));
LuaTables.put(t, "moveX", a -> move(Type.MOVE_X, a));
LuaTables.put(t, "moveY", a -> move(Type.MOVE_Y, a));
LuaTables.put(t, "moveZ", a -> move(Type.MOVE_Z, a));
LuaTables.put(t, "scale", a -> {
ops.add(new Op(Type.SCALE, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0),
(float) LuaTables.num(a, 4, 1), (float) LuaTables.num(a, 5, 8) * PX,
(float) LuaTables.num(a, 6, 8) * PX, (float) LuaTables.num(a, 7, 8) * PX));
return LuaValue.NIL;
});
return t;
}
private LuaValue rotate(Type type, Varargs a) {
ops.add(new Op(type, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0), (float) LuaTables.num(a, 4, 0),
(float) LuaTables.num(a, 5, 8) * PX, (float) LuaTables.num(a, 6, 8) * PX, (float) LuaTables.num(a, 7, 8) * PX));
return LuaValue.NIL;
}
private LuaValue move(Type type, Varargs a) {
ops.add(new Op(type, (int) LuaTables.num(a, 2, 0), (int) LuaTables.num(a, 3, 0),
(float) LuaTables.num(a, 4, 0) * PX, 0, 0, 0));
return LuaValue.NIL;
}
/** Вызывается миксином перед отправкой квадов на рендер: возвращает изменённую копию либо исходный список. */
public List<BakedQuad> apply(List<BakedQuad> quads) {
lastQuadCount = quads.size();
if (ops.isEmpty()) return quads;
List<BakedQuad> out = new ArrayList<>(quads.size());
for (int i = 0; i < quads.size(); i++) {
BakedQuad q = quads.get(i);
out.add(covered(i) ? transform(q, i) : q);
}
return out;
}
private boolean covered(int index) {
for (Op op : ops) if (op.covers(index)) return true;
return false;
}
private BakedQuad transform(BakedQuad q, int index) {
Vector3f[] p = new Vector3f[4];
for (int v = 0; v < 4; v++) {
float[] pos = {q.position(v).x(), q.position(v).y(), q.position(v).z()};
for (Op op : ops) if (op.covers(index)) QuadOps.apply(op, pos);
p[v] = new Vector3f(pos[0], pos[1], pos[2]);
}
return new BakedQuad(p[0], p[1], p[2], p[3], q.packedUV0(), q.packedUV1(), q.packedUV2(), q.packedUV3(),
q.direction(), q.materialInfo());
}
}

View file

@ -0,0 +1,53 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext;
/**
* Чистая математика аниматора частей модели: операции над вершиной (x,y,z в блоках, 0..1).
* Углы в градусах, сдвиги и pivot приходят из Lua в пикселях (1/16 блока) и уже переведены вызывающим.
*/
public final class QuadOps {
public enum Type { ROT_X, ROT_Y, ROT_Z, MOVE_X, MOVE_Y, MOVE_Z, SCALE }
/** Операция над квадами с индексами [from, to]; a: градусы/блоки/множитель, p: pivot в блоках. */
public record Op(Type type, int from, int to, float a, float px, float py, float pz) {
public boolean covers(int quadIndex) {
return quadIndex >= from && quadIndex <= to;
}
}
private QuadOps() {
}
/** Применяет операцию к вершине in-place. */
public static void apply(Op op, float[] v) {
switch (op.type()) {
case MOVE_X -> v[0] += op.a();
case MOVE_Y -> v[1] += op.a();
case MOVE_Z -> v[2] += op.a();
case SCALE -> {
v[0] = op.px() + (v[0] - op.px()) * op.a();
v[1] = op.py() + (v[1] - op.py()) * op.a();
v[2] = op.pz() + (v[2] - op.pz()) * op.a();
}
default -> rotate(op, v);
}
}
private static void rotate(Op op, float[] v) {
double rad = Math.toRadians(op.a());
float c = (float) Math.cos(rad);
float s = (float) Math.sin(rad);
float x = v[0] - op.px();
float y = v[1] - op.py();
float z = v[2] - op.pz();
float nx = x, ny = y, nz = z;
switch (op.type()) {
case ROT_X -> { ny = y * c - z * s; nz = y * s + z * c; }
case ROT_Y -> { nx = x * c + z * s; nz = -x * s + z * c; }
default -> { nx = x * c - y * s; ny = x * s + y * c; }
}
v[0] = nx + op.px();
v[1] = ny + op.py();
v[2] = nz + op.pz();
}
}

View file

@ -0,0 +1,73 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.ext;
import dev.loki.lovisual.features.module.modules.visuals.items.held.lua.api.LuaTables;
import org.luaj.vm2.LuaTable;
import org.luaj.vm2.LuaValue;
/**
* Lua-глобалы {@code V} и {@code C}.
* V: {@code V:cancelEquip(true)} отключает ванильный подъём предмета, {@code V:cancelSwing(true)} ванильный свинг
* (скрипт сам рисует их через hand_pose); флаги читаются миксином на следующем кадре.
* C: {@code C:setCamRot(yaw, pitch)} добавка к повороту камеры в градусах, {@code C:setCamPos(surge, heave, sway)}
* сдвиг камеры в блоках; действует, пока скрипт обновляет значения (до 150 мс без вызова).
*/
public final class VanillaApi {
private static final long CAM_TTL_MS = 150L;
private final boolean[] cancelEquip = new boolean[2];
private final boolean[] cancelSwing = new boolean[2];
private final float[] cam = new float[5];
private long camStamp;
private int current;
/** Начало кадра руки: флаги этой руки сбрасываются и выставляются скриптом заново. */
public void begin(boolean mainHand) {
current = mainHand ? 0 : 1;
cancelEquip[current] = false;
cancelSwing[current] = false;
}
public boolean cancelEquip(boolean mainHand) {
return cancelEquip[mainHand ? 0 : 1];
}
public boolean cancelSwing(boolean mainHand) {
return cancelSwing[mainHand ? 0 : 1];
}
/** Добавка камеры {yaw, pitch, surge, heave, sway}, либо null, если скрипт её не задаёт. */
public float[] cameraOffset() {
return System.currentTimeMillis() - camStamp > CAM_TTL_MS ? null : cam;
}
public LuaTable vanillaTable() {
LuaTable t = new LuaTable();
LuaTables.put(t, "cancelEquip", a -> flag(cancelEquip, a.arg(2).toboolean()));
LuaTables.put(t, "cancelSwing", a -> flag(cancelSwing, a.arg(2).toboolean()));
return t;
}
public LuaTable cameraTable() {
LuaTable t = new LuaTable();
LuaTables.put(t, "setCamRot", a -> cam(0, a.arg(2).optdouble(0), a.arg(3).optdouble(0)));
LuaTables.put(t, "setCamPos", a -> {
cam(2, a.arg(2).optdouble(0), a.arg(3).optdouble(0));
cam[4] = (float) a.arg(4).optdouble(0);
return LuaValue.NIL;
});
return t;
}
private LuaValue flag(boolean[] array, boolean value) {
array[current] = value;
return LuaValue.NIL;
}
private LuaValue cam(int at, double first, double second) {
cam[at] = (float) first;
cam[at + 1] = (float) second;
camStamp = System.currentTimeMillis();
return LuaValue.NIL;
}
}

View file

@ -0,0 +1,55 @@
package dev.loki.lovisual.features.module.modules.visuals.items.held.lua.runtime;
import net.fabricmc.loader.api.FabricLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Поиск исходников скриптов: {@code config/lovisual/holdmyitems/<name>.lua}, иначе встроенный пример
* из ресурсов мода (только для основных скриптов; аддоны бывают лишь в конфиге).
*/
public final class ScriptFiles {
private static final Logger LOG = LoggerFactory.getLogger(ScriptFiles.class);
private ScriptFiles() {
}
public static Path dir() {
return FabricLoader.getInstance().getConfigDir().resolve("lovisual").resolve("holdmyitems");
}
/** Метка изменения: mtime файла, 0 для встроенного примера, -1 если источника нет. */
public static long stamp(String name, boolean builtinAllowed) {
Path file = dir().resolve(name + ".lua");
try {
if (Files.isRegularFile(file)) return Files.getLastModifiedTime(file).toMillis();
} catch (IOException e) {
return -1L;
}
return builtinAllowed && ScriptFiles.class.getResource(resource(name)) != null ? 0L : -1L;
}
public static String read(String name) {
Path file = dir().resolve(name + ".lua");
try {
if (Files.isRegularFile(file)) return Files.readString(file, StandardCharsets.UTF_8);
try (InputStream in = ScriptFiles.class.getResourceAsStream(resource(name))) {
return in == null ? null : new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
} catch (IOException e) {
LOG.warn("Cannot read HoldMyItems script {}: {}", name, e.getMessage());
return null;
}
}
private static String resource(String name) {
return "/assets/lovisual/holdmyitems/" + name + ".lua";
}
}

View file

@ -3,9 +3,9 @@ package dev.loki.lovisual.features.module.modules.visuals.lyrics;
import java.util.ArrayList;
import java.util.List;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcLibClient;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLineSplitter;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricsFinder;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.mode.LyricModes;
import dev.loki.lovisual.util.media.session.MediaSessionService;
import net.minecraft.client.Minecraft;
@ -24,6 +24,9 @@ final class LyricPlayback {
/** Регрессия больше этого — пауза/откат: строки после индекса уже не догнать, нужен resync. */
private static final long RESYNC_BACKWARD_MS = 750L;
private static final int MAX_RETRIES = 6;
private static final long RETRY_STEP_MS = 8_000L;
private final KineticLyrics owner;
private final LyricStage stage;
@ -36,6 +39,9 @@ final class LyricPlayback {
private String currentTrackArtist = "";
private long lastAudioTimeMs = -1L;
private boolean isPlaying = false;
private boolean fetching = false;
private int retries = 0;
private long retryAtMs = 0L;
LyricPlayback(KineticLyrics owner, LyricStage stage) {
this.owner = owner;
@ -69,6 +75,9 @@ final class LyricPlayback {
currentLyricIndex = 0;
lastAudioTimeMs = -1L;
isPlaying = false;
fetching = false;
retries = 0;
retryAtMs = 0L;
}
/** Poll the media session once: switch tracks, resync on seek/pause and spawn due lines. */
@ -87,10 +96,15 @@ final class LyricPlayback {
String key = (title + " - " + artist).toLowerCase();
if (!key.equalsIgnoreCase(lastTrackKey)) {
loadTrack(title, artist, key);
loadTrack(title, artist, key, snapshot.durationSeconds());
return;
}
if (lyricsQueue.isEmpty() && !fetching && retryAtMs > 0L && System.currentTimeMillis() >= retryAtMs) {
retryAtMs = 0L;
requestLyrics(title, artist, key, snapshot.durationSeconds());
}
if (!isPlaying && !lyricsQueue.isEmpty()) {
isPlaying = true;
}
@ -118,21 +132,30 @@ final class LyricPlayback {
}
}
private void loadTrack(String title, String artist, String key) {
private void loadTrack(String title, String artist, String key, long durationSeconds) {
// reset() forgets the track key, so it has to run before the new track is recorded
reset();
this.currentTrackTitle = title;
this.currentTrackArtist = artist;
this.lastTrackKey = key;
requestLyrics(title, artist, key, durationSeconds);
}
reset();
LrcLibClient.fetchLyricsAsync(title, artist, owner.debugMode.get()).thenAccept(lines ->
private void requestLyrics(String title, String artist, String key, long durationSeconds) {
fetching = true;
LyricsFinder.findAsync(title, artist, durationSeconds, owner.debugMode.get()).thenAccept(result ->
Minecraft.getInstance().execute(() -> {
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
fetching = false;
if (result.lines().isEmpty()) {
scheduleRetry(result.retryLater());
return;
}
stage.clear();
rawLyrics.clear();
lyricsQueue.clear();
rawLyrics.addAll(lines);
rawLyrics.addAll(result.lines());
rebuildLyricsQueue();
currentLyricIndex = 0;
isPlaying = !lyricsQueue.isEmpty();
@ -140,6 +163,13 @@ final class LyricPlayback {
);
}
/** A busy service is asked again after a growing pause, a few times; a song nobody has is left alone. */
private void scheduleRetry(boolean retryLater) {
if (!retryLater || retries >= MAX_RETRIES) return;
retries++;
retryAtMs = System.currentTimeMillis() + RETRY_STEP_MS * retries;
}
private void rebuildLyricsQueue() {
lyricsQueue.clear();
if (owner.splitMode.get() == LyricModes.SplitMode.SMART_SPLIT) {

View file

@ -1,187 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.loki.lovisual.util.logging.DebugLog;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.KineticLyrics;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
/**
* Fetches synced lyrics from lrclib.net (no API key required).
*/
public final class LrcLibClient {
private static final String API_GET = "https://lrclib.net/api/get";
private static final String API_SEARCH = "https://lrclib.net/api/search";
private static final String USER_AGENT = "LoVisual/1.0";
private static final Map<String, List<LyricLine>> LYRICS_CACHE = new ConcurrentHashMap<>();
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(12))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private LrcLibClient() {
}
public static CompletableFuture<List<LyricLine>> fetchLyricsAsync(String trackName, String artistName, boolean debugMode) {
if (trackName == null || trackName.isBlank()) {
return CompletableFuture.completedFuture(Collections.emptyList());
}
String cacheKey = (trackName.trim() + " - " + (artistName != null ? artistName.trim() : "")).toLowerCase();
List<LyricLine> cached = LYRICS_CACHE.get(cacheKey);
if (cached != null) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Cache hit: %s (%d lines)", cacheKey, cached.size());
}
return CompletableFuture.completedFuture(cached);
}
return CompletableFuture.supplyAsync(() -> {
List<LyricLine> lines = tryFetchGet(trackName, artistName, debugMode);
if (!lines.isEmpty()) {
LYRICS_CACHE.put(cacheKey, lines);
return lines;
}
lines = tryFetchSearch(trackName, artistName, debugMode);
if (!lines.isEmpty()) {
LYRICS_CACHE.put(cacheKey, lines);
return lines;
}
return Collections.<LyricLine>emptyList();
});
}
private static List<LyricLine> tryFetchGet(String trackName, String artistName, boolean debugMode) {
String cleanTrack = cleanTrackName(trackName);
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
String query = String.format("?track_name=%s&artist_name=%s",
URLEncoder.encode(cleanTrack, StandardCharsets.UTF_8),
URLEncoder.encode(cleanArtist, StandardCharsets.UTF_8));
return executeRequest(API_GET + query, debugMode);
}
private static List<LyricLine> tryFetchSearch(String trackName, String artistName, boolean debugMode) {
String cleanTrack = cleanTrackName(trackName);
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
String searchQuery = (cleanTrack + " " + cleanArtist).trim();
String query = "?q=" + URLEncoder.encode(searchQuery, StandardCharsets.UTF_8);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_SEARCH + query))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(14))
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonArray array = JsonParser.parseString(response.body()).getAsJsonArray();
for (JsonElement elem : array) {
if (elem.isJsonObject()) {
JsonObject obj = elem.getAsJsonObject();
if (obj.has("syncedLyrics") && !obj.get("syncedLyrics").isJsonNull()) {
String syncedLrc = obj.get("syncedLyrics").getAsString();
List<LyricLine> parsed = LrcParser.parse(syncedLrc);
if (!parsed.isEmpty()) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Found lyrics via search fallback");
}
return parsed;
}
}
}
}
} else if (response.statusCode() == 404) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Search: track not found (404)");
}
}
} catch (Exception e) {
if (debugMode) {
DebugLog.error("[KineticLyrics] Search fallback failed", e);
}
}
return Collections.emptyList();
}
private static List<LyricLine> executeRequest(String url, boolean debugMode) {
for (int attempt = 1; attempt <= 2; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(14))
.GET()
.build();
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
if (json.has("syncedLyrics") && !json.get("syncedLyrics").isJsonNull()) {
String syncedLrc = json.get("syncedLyrics").getAsString();
return LrcParser.parse(syncedLrc);
}
if (debugMode) {
DebugLog.info("[KineticLyrics] No syncedLyrics in response");
}
return Collections.emptyList();
} else if (response.statusCode() == 404) {
if (debugMode) {
DebugLog.info("[KineticLyrics] Track not found (404)");
}
return Collections.emptyList();
} else {
if (debugMode) {
DebugLog.info("[KineticLyrics] Attempt %d returned %d", attempt, response.statusCode());
}
}
} catch (Exception e) {
if (debugMode) {
DebugLog.error("[KineticLyrics] Attempt " + attempt + " failed", e);
}
if (attempt < 2) {
try {
Thread.sleep(400);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
}
}
return Collections.emptyList();
}
public static String cleanTrackName(String trackName) {
if (trackName == null) return "";
return trackName
.replaceAll("\\(.*?\\)", "")
.replaceAll("\\[.*?\\]", "")
.trim();
}
}

View file

@ -0,0 +1,63 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/** Pure scoring of a search hit against the track that is playing (0 = unrelated, 1 = certain). */
public final class LyricMatch {
/** Lowest score still trusted as "the same song". */
public static final double ACCEPT = 0.6;
/** A hit this good ends the search early. */
public static final double CERTAIN = 0.92;
private static final double TITLE_FLOOR = 0.5;
private static final long CLOSE_SECONDS = 2;
private static final long FAR_SECONDS = 15;
private LyricMatch() {
}
public static double score(String hitTitle, String hitArtist, long hitDurationSeconds, TrackQuery query) {
double title = similarity(hitTitle, query.cleanTitle());
if (title < TITLE_FLOOR) return 0.0;
double artist = artistScore(hitArtist, query);
double duration = durationScore(hitDurationSeconds, query.durationSeconds());
return 0.55 * title + 0.30 * artist + 0.15 * duration;
}
/** Token similarity: 1 for equal text, high when one contains the other, Jaccard otherwise. */
public static double similarity(String a, String b) {
Set<String> left = tokens(a);
Set<String> right = tokens(b);
if (left.isEmpty() || right.isEmpty()) return 0.0;
if (left.equals(right)) return 1.0;
if (left.containsAll(right) || right.containsAll(left)) return 0.85;
Set<String> common = new HashSet<>(left);
common.retainAll(right);
Set<String> all = new HashSet<>(left);
all.addAll(right);
return (double) common.size() / all.size();
}
public static double artistScore(String hitArtist, TrackQuery query) {
if (query.artist().isEmpty()) return 0.5;
return Math.max(similarity(hitArtist, query.cleanArtist()), similarity(hitArtist, query.primaryArtist()));
}
public static double durationScore(long hitSeconds, long wantedSeconds) {
if (hitSeconds <= 0 || wantedSeconds <= 0) return 0.5;
long diff = Math.abs(hitSeconds - wantedSeconds);
if (diff <= CLOSE_SECONDS) return 1.0;
if (diff >= FAR_SECONDS) return 0.0;
return 1.0 - (double) (diff - CLOSE_SECONDS) / (FAR_SECONDS - CLOSE_SECONDS);
}
private static Set<String> tokens(String text) {
if (text == null) return Set.of();
String flat = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
if (flat.isEmpty()) return Set.of();
return new HashSet<>(Arrays.asList(flat.split(" ")));
}
}

View file

@ -0,0 +1,39 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import java.util.ArrayList;
import java.util.List;
/** Gives unsynced lyrics a plausible timeline by spreading the lines over the song, weighted by length. */
public final class PlainLyricTimer {
private static final double INTRO = 0.06;
private static final double OUTRO = 0.94;
private static final int MIN_WEIGHT = 8;
private PlainLyricTimer() {
}
public static List<LyricLine> estimate(String plain, long durationSeconds) {
List<LyricLine> out = new ArrayList<>();
if (plain == null || durationSeconds <= 0) return out;
List<String> lines = new ArrayList<>();
for (String raw : plain.split("\\r?\\n")) {
String line = raw.trim();
if (!line.isEmpty() && !line.matches("\\[[^\\]]*]")) lines.add(line);
}
if (lines.isEmpty()) return out;
double total = 0;
for (String line : lines) total += Math.max(MIN_WEIGHT, line.length());
double startMs = durationSeconds * 1000.0 * INTRO;
double span = durationSeconds * 1000.0 * (OUTRO - INTRO);
double cursor = 0;
for (String line : lines) {
out.add(new LyricLine(Math.round(startMs + span * cursor / total), line));
cursor += Math.max(MIN_WEIGHT, line.length());
}
return out;
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match;
import java.util.regex.Pattern;
/** The track being looked up: raw player metadata plus the cleaned forms the lyric services want. */
public record TrackQuery(String title, String artist, long durationSeconds) {
private static final Pattern BRACKETS = Pattern.compile("\\(.*?\\)|\\[.*?]");
private static final Pattern ARTIST_SPLIT =
Pattern.compile("\\s*(?:,|;|&|\\bfeat\\.?\\b|\\bft\\.?\\b|\\bwith\\b|\\bx\\b| / )\\s*", Pattern.CASE_INSENSITIVE);
public TrackQuery {
title = title == null ? "" : title.trim();
artist = artist == null ? "" : artist.trim();
}
/** Title without "(feat. X)", "[Remix]" and similar decoration. */
public static String clean(String text) {
if (text == null) return "";
return BRACKETS.matcher(text).replaceAll("").trim();
}
public String cleanTitle() {
String cleaned = clean(title);
return cleaned.isEmpty() ? title : cleaned;
}
public String cleanArtist() {
return clean(artist);
}
/** First credited artist ("A, B & C" gives "A"). */
public String primaryArtist() {
String cleaned = cleanArtist();
String[] parts = ARTIST_SPLIT.split(cleaned, 2);
return parts.length == 0 ? cleaned : parts[0].trim();
}
/** Free-text query for services that search by one string. */
public String searchText() {
return (cleanTitle() + " " + cleanArtist()).trim();
}
}

View file

@ -0,0 +1,128 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.LyricMatch;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.PlainLyricTimer;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import static dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricHttp.encode;
/** lrclib.net: exact lookup by duration first, then several searches ranked by how well they match. */
final class LrcLibSource {
private static final String API = "https://lrclib.net/api/";
private static final double SYNCED_BONUS = 0.08;
private LrcLibSource() {
}
static List<LyricLine> find(TrackQuery query, LyricHttp http) {
String title = query.cleanTitle();
if (!query.artist().isEmpty() && query.durationSeconds() > 0) {
String body = http.get(API + "get?artist_name=" + encode(query.cleanArtist())
+ "&track_name=" + encode(title) + "&duration=" + query.durationSeconds());
JsonObject exact = body == null ? null : object(body);
if (exact != null) {
List<LyricLine> synced = timed(exact);
if (!synced.isEmpty()) return synced;
}
}
JsonObject best = null;
double bestScore = 0.0;
for (String url : searchUrls(query)) {
String body = http.get(url);
JsonArray hits = body == null ? null : array(body);
if (hits == null) continue;
for (JsonElement element : hits) {
if (!element.isJsonObject()) continue;
JsonObject hit = element.getAsJsonObject();
double score = scoreOf(hit, query);
if (score > bestScore) {
best = hit;
bestScore = score;
}
}
if (bestScore >= LyricMatch.CERTAIN) break;
}
if (best == null || bestScore < LyricMatch.ACCEPT) return List.of();
return lines(best, query);
}
static Set<String> searchUrls(TrackQuery query) {
String title = encode(query.cleanTitle());
Set<String> urls = new LinkedHashSet<>();
if (!query.artist().isEmpty()) {
urls.add(API + "search?track_name=" + title + "&artist_name=" + encode(query.cleanArtist()));
urls.add(API + "search?track_name=" + title + "&artist_name=" + encode(query.primaryArtist()));
}
urls.add(API + "search?q=" + encode(query.searchText()));
urls.add(API + "search?track_name=" + title);
return urls;
}
/** Hit score; instrumentals and hits without any lyrics score zero, synced ones get a small bonus. */
static double scoreOf(JsonObject hit, TrackQuery query) {
if (flag(hit, "instrumental")) return 0.0;
boolean synced = text(hit, "syncedLyrics") != null;
if (!synced && text(hit, "plainLyrics") == null) return 0.0;
double base = LyricMatch.score(text(hit, "trackName"), text(hit, "artistName"),
Math.round(number(hit, "duration")), query);
return base > 0.0 ? base + (synced ? SYNCED_BONUS : 0.0) : 0.0;
}
private static List<LyricLine> lines(JsonObject hit, TrackQuery query) {
List<LyricLine> synced = timed(hit);
if (!synced.isEmpty()) return synced;
long duration = Math.round(number(hit, "duration"));
return PlainLyricTimer.estimate(text(hit, "plainLyrics"), duration > 0 ? duration : query.durationSeconds());
}
private static List<LyricLine> timed(JsonObject hit) {
String synced = text(hit, "syncedLyrics");
return synced == null ? List.of() : LrcParser.parse(synced);
}
private static JsonObject object(String body) {
try {
JsonElement parsed = JsonParser.parseString(body);
return parsed.isJsonObject() ? parsed.getAsJsonObject() : null;
} catch (RuntimeException e) {
return null;
}
}
private static JsonArray array(String body) {
try {
JsonElement parsed = JsonParser.parseString(body);
return parsed.isJsonArray() ? parsed.getAsJsonArray() : null;
} catch (RuntimeException e) {
return null;
}
}
private static String text(JsonObject object, String key) {
JsonElement element = object.get(key);
if (element == null || !element.isJsonPrimitive()) return null;
String value = element.getAsString();
return value.isBlank() ? null : value;
}
private static double number(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber() ? element.getAsDouble() : 0.0;
}
private static boolean flag(JsonObject object, String key) {
JsonElement element = object.get(key);
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean() && element.getAsBoolean();
}
}

View file

@ -0,0 +1,67 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* One lookup's HTTP session. A 404 is a plain "not found"; a 429, 5xx or network error is remembered as
* transient so the caller can tell "nothing exists" from "the service was busy, try again later".
*/
final class LyricHttp {
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private static final String AGENT = "Mozilla/5.0 (X11; Linux x86_64) LoVisual/1.0";
private static final int ATTEMPTS = 2;
private boolean transientFailure;
static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
boolean sawTransientFailure() {
return transientFailure;
}
/** @return the response body, or null when there is none (missing or failed). */
String get(String url) {
for (int attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("User-Agent", AGENT)
.timeout(Duration.ofSeconds(12))
.GET()
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status == 200) return response.body();
if (status == 404) return null;
if (status != 429 && status < 500) return null;
} catch (IOException e) {
// network trouble is worth retrying
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
if (attempt < ATTEMPTS) pause();
}
transientFailure = true;
return null;
}
private static void pause() {
try {
Thread.sleep(350);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View file

@ -0,0 +1,55 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
import dev.loki.lovisual.util.logging.DebugLog;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Finds timed lyrics for a track: lrclib first (exact lookup, then ranked searches, unsynced text gets an
* estimated timeline), NetEase as the fallback. Only successes are cached; a result that failed because a
* service was busy says so, so the caller retries instead of giving up on the song.
*/
public final class LyricsFinder {
/** @param retryLater no lyrics were found, but a service was busy or unreachable, so asking again may help */
public record Result(List<LyricLine> lines, boolean retryLater) {
static final Result NOTHING = new Result(List.of(), false);
}
private static final Map<String, List<LyricLine>> CACHE = new ConcurrentHashMap<>();
private LyricsFinder() {
}
public static CompletableFuture<Result> findAsync(String title, String artist, long durationSeconds, boolean debug) {
TrackQuery query = new TrackQuery(title, artist, durationSeconds);
if (query.title().isEmpty()) return CompletableFuture.completedFuture(Result.NOTHING);
String key = (query.title() + " - " + query.artist()).toLowerCase();
List<LyricLine> cached = CACHE.get(key);
if (cached != null) return CompletableFuture.completedFuture(new Result(cached, false));
return CompletableFuture.supplyAsync(() -> {
LyricHttp http = new LyricHttp();
List<LyricLine> lines = LrcLibSource.find(query, http);
String source = "lrclib";
if (lines.isEmpty()) {
lines = NeteaseSource.find(query, http);
source = "netease";
}
if (debug) {
DebugLog.info("[KineticLyrics] %s: %d lines via %s%s", key, lines.size(), source,
http.sawTransientFailure() ? " (a service was busy)" : "");
}
if (!lines.isEmpty()) {
CACHE.put(key, lines);
return new Result(lines, false);
}
return new Result(List.of(), http.sawTransientFailure());
});
}
}

View file

@ -0,0 +1,85 @@
package dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LrcParser;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.LyricLine;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.LyricMatch;
import dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.match.TrackQuery;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import static dev.loki.lovisual.features.module.modules.visuals.lyrics.lrc.source.LyricHttp.encode;
/** NetEase Cloud Music: the fallback for songs lrclib does not have (it covers a lot of non-Western music). */
final class NeteaseSource {
private static final String SEARCH = "https://music.163.com/api/cloudsearch/pc?type=1&limit=10&offset=0&s=";
private static final String LYRIC = "https://music.163.com/api/song/lyric?os=pc&lv=-1&kv=-1&tv=-1&id=";
private static final Pattern CREDIT = Pattern.compile("^[^::]{1,24}\\s*[::]\\s*.{0,60}$");
private static final long CREDIT_WINDOW_MS = 2000;
private NeteaseSource() {
}
static List<LyricLine> find(TrackQuery query, LyricHttp http) {
String body = http.get(SEARCH + encode(query.searchText()));
long id = body == null ? 0 : bestSongId(body, query);
if (id == 0) return List.of();
String lyricBody = http.get(LYRIC + id);
return lyricBody == null ? List.of() : stripCredits(LrcParser.parse(lyric(lyricBody)));
}
/** Id of the best matching song in a cloudsearch response, or 0 when nothing is close enough. */
static long bestSongId(String json, TrackQuery query) {
try {
JsonElement songs = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("result").get("songs");
if (songs == null || !songs.isJsonArray()) return 0;
long bestId = 0;
double bestScore = 0.0;
for (JsonElement element : songs.getAsJsonArray()) {
JsonObject song = element.getAsJsonObject();
double score = LyricMatch.score(song.get("name").getAsString(), artists(song.getAsJsonArray("ar")),
song.has("dt") ? Math.round(song.get("dt").getAsLong() / 1000.0) : 0, query);
if (score > bestScore) {
bestScore = score;
bestId = song.get("id").getAsLong();
}
}
return bestScore >= LyricMatch.ACCEPT ? bestId : 0;
} catch (RuntimeException e) {
return 0;
}
}
/** Drops the "Lyrics by / Composer :" lines NetEase puts at the very start of a song. */
static List<LyricLine> stripCredits(List<LyricLine> lines) {
List<LyricLine> out = new ArrayList<>(lines);
while (!out.isEmpty() && out.get(0).timestampMs() <= CREDIT_WINDOW_MS && CREDIT.matcher(out.get(0).text()).matches()) {
out.remove(0);
}
return out;
}
private static String lyric(String json) {
try {
JsonObject lrc = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("lrc");
return lrc == null || !lrc.has("lyric") ? "" : lrc.get("lyric").getAsString();
} catch (RuntimeException e) {
return "";
}
}
private static String artists(JsonArray array) {
if (array == null) return "";
StringBuilder out = new StringBuilder();
for (JsonElement element : array) {
JsonElement name = element.getAsJsonObject().get("name");
if (name != null && !name.isJsonNull()) out.append(name.getAsString()).append(' ');
}
return out.toString().trim();
}
}

View file

@ -0,0 +1,12 @@
package dev.loki.lovisual.features.module.modules.visuals.models;
/**
* Implemented by LivingEntityRenderState. The model a player is drawn with is chosen once per frame, while the
* entity is still at hand, and carried here to the moment the model is submitted.
*/
public interface CustomModelState {
/** The chosen model name, or null when this player is drawn as a player. */
String lovisual$customModel();
void lovisual$setCustomModel(String model);
}

View file

@ -0,0 +1,120 @@
package dev.loki.lovisual.features.module.modules.visuals.models;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.ChickenMesh;
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.RabbitModel;
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.VerityMesh;
import dev.loki.lovisual.features.relations.category.CategoryService;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.Model;
import net.minecraft.client.model.player.PlayerModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.Avatar;
import net.minecraft.world.entity.player.Player;
import java.util.HashMap;
import java.util.Map;
/**
* Draws chosen players with a model of their own instead of their skin. Only this client sees it: nothing about
* the players changes and nothing is sent anywhere. The choice is made per player at render-state extraction
* (AvatarCustomModelMixin) and the replacement happens where the model is submitted (LivingEntityCustomModelMixin).
*/
@ModuleInfo(
id = "custommodels",
displayName = "Custom Models",
aliases = {"custommodels", "models"},
category = ModuleCategory.VISUALS
)
public class CustomModels extends Module {
public static final String CHICKEN = "Chicken";
public static final String VERITY = "Verity";
public static final String RABBIT = "Rabbit";
private static final String[] FACES = {"Normal", "Talking", "Disturbing", "Disturbing talking",
"Disturbing yelling", "Bad", "Very bad"};
private static final String[] FACE_FILES = {"verity", "veritytalk", "veritydisturbing", "veritydisturbingtalk",
"veritydisturbingyell", "veritybad", "verityverybad"};
private static final int MAX_RABBITS = 128;
private final ModeValue model = modeSetting("customModelsModel", "model", CHICKEN, CHICKEN, VERITY, RABBIT);
private final BooleanValue self = bool("customModelsSelf", "self", true);
private final BooleanValue friends = bool("customModelsFriends", "friends", false);
private final BooleanValue others = bool("customModelsOthers", "others", false);
private final BooleanValue hand = visibleWhen(bool("customModelsHand", "hand", true),
() -> CHICKEN.equals(model.get()) && self.get());
private final NumberValue<Float> size = visibleWhen(num("customModelsSize", "size", 1.0f, 0.3f, 3.0f),
() -> VERITY.equals(model.get()));
private final ModeValue face = visibleWhen(modeSetting("customModelsFace", "face", FACES[0], FACES),
() -> VERITY.equals(model.get()));
private final ChickenMesh chicken = new ChickenMesh();
private final VerityMesh verity = new VerityMesh();
private final Map<Integer, RabbitModel> rabbits = new HashMap<>();
@Override
public void onDisable() {
rabbits.clear();
}
/** The model this player is to be drawn with, or null to leave them as they are. */
public String modelFor(Avatar avatar) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || avatar == null || mc.player == null) return null;
boolean wanted;
if (avatar.getUUID().equals(mc.player.getUUID())) {
wanted = self.get();
} else if (avatar instanceof Player player && CategoryService.isFriend(player)) {
wanted = friends.get();
} else {
wanted = others.get();
}
return wanted ? model.get() : null;
}
/** Whether the hand the player looks at is drawn as the model's arm rather than their own. */
public boolean handFromModel() {
return isEnabled() && CHICKEN.equals(model.get()) && self.get() && hand.get();
}
public void submitHand(PoseStack pose, SubmitNodeCollector collector, int light, boolean right) {
chicken.submitArm(pose, collector, light, right);
}
/** Draws {@code custom} in place of the player model; false when the name is not one of ours. */
public boolean submit(String custom, Model<?> vanilla, AvatarRenderState state, PoseStack pose,
SubmitNodeCollector collector, int light, int overlay, int color, int outlineColor) {
if (RABBIT.equals(custom) && vanilla instanceof PlayerModel player) {
if (rabbits.size() > MAX_RABBITS) rabbits.clear();
rabbits.computeIfAbsent(state.id, id -> new RabbitModel())
.submit(player, state, pose, collector, light, overlay, color, outlineColor);
} else if (VERITY.equals(custom)) {
verity.submit(pose, collector, light, overlay, verityTexture(), size.get());
} else if (CHICKEN.equals(custom)) {
chicken.submit(state, pose, collector, light, overlay);
} else {
return false;
}
return true;
}
/** The face texture chosen for Verity. */
Identifier verityTexture() {
return Identifier.fromNamespaceAndPath("lovisual", "models/verity/" + faceFile(face.get()) + ".png");
}
static String faceFile(String faceName) {
for (int i = 0; i < FACES.length; i++) {
if (FACES[i].equals(faceName)) return FACE_FILES[i];
}
return FACE_FILES[0];
}
}

View file

@ -0,0 +1,136 @@
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* The chicken: textured geometry split into movable parts, drawn in place of the player model. Parts are
* stored as triangle lists, one file per part and material, read on first use. Limbs swing with the walk, the
* hitting arm swings out with the attack, and held items ride the paws.
*/
public final class ChickenMesh {
private static final String[] PARTS = {"body", "head", "right_arm", "left_arm", "right_leg", "left_leg"};
private static final int RIGHT_ARM = 2;
private static final int LEFT_ARM = 3;
private static final Identifier[] TEXTURES = {
Identifier.fromNamespaceAndPath("lovisual", "models/chicken/rudy_sc_1.png"),
Identifier.fromNamespaceAndPath("lovisual", "models/chicken/bruce_col.png")
};
/** Rotation pivots of each part, measured from the converted mesh, in the model's own space. */
private static final float[][] PIVOTS = {
{0, 0, 0}, {0, 0.55f, 0}, {-0.52f, 0.55f, 0}, {0.52f, 0.55f, 0}, {-0.18f, 1.12f, 0}, {0.18f, 1.12f, 0}
};
private static final int MAX_VERTICES = 1_000_000;
private float[][][] geometry;
public void submit(AvatarRenderState state, PoseStack pose, SubmitNodeCollector collector, int light, int overlay) {
float[][][] data = geometry();
float stride = Math.min(state.walkAnimationSpeed, 1.0f);
float swing = (float) Math.cos(state.walkAnimationPos * 0.6662f) * 1.2f * stride;
float rightArm = -swing;
float leftArm = swing;
if (state.attackTime > 0) {
float hit = (float) Math.sin(Math.sqrt(state.attackTime) * Math.PI) * 1.3f;
if (state.attackArm == HumanoidArm.RIGHT) rightArm -= hit;
else leftArm -= hit;
}
if (state.isUsingItem) {
boolean mainHand = state.useItemHand == InteractionHand.MAIN_HAND;
if (mainHand == (state.mainArm == HumanoidArm.RIGHT)) rightArm = -0.8f;
else leftArm = -0.8f;
}
float[] angles = {0, 0, rightArm, leftArm, swing, -swing};
for (int part = 0; part < PARTS.length; part++) {
pose.pushPose();
rotatePart(pose, part, angles[part]);
for (int material = 0; material < TEXTURES.length; material++) {
MeshVertices.submit(pose, collector, TEXTURES[material], data[part][material], light, overlay);
}
pose.popPose();
}
submitItem(state.rightHandItemState, pose, collector, light, state.outlineColor, RIGHT_ARM, rightArm);
submitItem(state.leftHandItemState, pose, collector, light, state.outlineColor, LEFT_ARM, leftArm);
}
/**
* One arm on its own, for the hand the player sees in front of them. It is shifted so its shoulder sits where
* the vanilla arm's shoulder does; the swing, item and equip movement are whatever the game already set up.
*/
public void submitArm(PoseStack pose, SubmitNodeCollector collector, int light, boolean right) {
float[][][] data = geometry();
int part = right ? RIGHT_ARM : LEFT_ARM;
float[] pivot = PIVOTS[part];
pose.pushPose();
pose.translate(-pivot[0], -pivot[1], -pivot[2]);
for (int material = 0; material < TEXTURES.length; material++) {
MeshVertices.submit(pose, collector, TEXTURES[material], data[part][material], light, OverlayTexture.NO_OVERLAY);
}
pose.popPose();
}
private static void rotatePart(PoseStack pose, int part, float angle) {
if (angle == 0) return;
float[] pivot = PIVOTS[part];
pose.translate(pivot[0], pivot[1], pivot[2]);
pose.mulPose(Axis.XP.rotation(angle));
pose.translate(-pivot[0], -pivot[1], -pivot[2]);
}
private static void submitItem(ItemStackRenderState item, PoseStack pose, SubmitNodeCollector collector,
int light, int outlineColor, int arm, float angle) {
if (item.isEmpty()) return;
pose.pushPose();
rotatePart(pose, arm, angle);
// the paw tip, with the item in the standard third-person hand orientation
pose.translate(arm == RIGHT_ARM ? -0.57f : 0.57f, 0.92f, -0.02f);
pose.mulPose(Axis.XP.rotationDegrees(-90));
pose.mulPose(Axis.YP.rotationDegrees(180));
pose.translate(arm == RIGHT_ARM ? 0.0625f : -0.0625f, 0.125f, 0);
item.submit(pose, collector, light, OverlayTexture.NO_OVERLAY, outlineColor);
pose.popPose();
}
private float[][][] geometry() {
if (geometry == null) {
float[][][] all = new float[PARTS.length][TEXTURES.length][];
for (int part = 0; part < PARTS.length; part++) {
for (int material = 0; material < TEXTURES.length; material++) {
all[part][material] = load(PARTS[part], material);
}
}
geometry = all;
}
return geometry;
}
private static float[] load(String part, int material) {
String path = "/assets/lovisual/models/chicken/mesh_" + part + "_" + material + ".bin";
try (InputStream raw = ChickenMesh.class.getResourceAsStream(path)) {
return raw == null ? new float[0] : read(raw);
} catch (IOException | RuntimeException e) {
return new float[0];
}
}
/** A vertex count followed by that many vertices of eight big-endian floats each. */
static float[] read(InputStream raw) throws IOException {
DataInputStream in = new DataInputStream(raw);
int count = in.readInt();
if (count < 0 || count > MAX_VERTICES || count % 3 != 0) throw new IOException("Invalid vertex count");
float[] data = new float[count * MeshVertices.FLOATS_PER_VERTEX];
for (int i = 0; i < data.length; i++) data[i] = in.readFloat();
return data;
}
}

View file

@ -0,0 +1,40 @@
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.resources.Identifier;
/**
* Triangle lists as plain floats: eight per vertex (position, uv, normal), three vertices per triangle.
* Shared by every mesh model so they all emit geometry the same way.
*/
public final class MeshVertices {
public static final int FLOATS_PER_VERTEX = 8;
private static final int FLOATS_PER_TRIANGLE = 3 * FLOATS_PER_VERTEX;
private MeshVertices() {
}
/** Submits the triangles as a cutout entity layer; a quad's fourth corner repeats the third. */
public static void submit(PoseStack pose, SubmitNodeCollector collector, Identifier texture,
float[] triangles, int light, int overlay) {
if (triangles.length == 0) return;
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture), (matrix, consumer) -> {
for (int t = 0; t + FLOATS_PER_TRIANGLE <= triangles.length; t += FLOATS_PER_TRIANGLE) {
vertex(matrix, consumer, triangles, t, light, overlay);
vertex(matrix, consumer, triangles, t + FLOATS_PER_VERTEX, light, overlay);
vertex(matrix, consumer, triangles, t + 2 * FLOATS_PER_VERTEX, light, overlay);
vertex(matrix, consumer, triangles, t + 2 * FLOATS_PER_VERTEX, light, overlay);
}
});
}
private static void vertex(PoseStack.Pose pose, VertexConsumer consumer, float[] data, int i, int light, int overlay) {
consumer.addVertex(pose, data[i], data[i + 1], data[i + 2])
.setColor(-1).setUv(data[i + 3], data[i + 4])
.setOverlay(overlay).setLight(light)
.setNormal(pose, data[i + 5], data[i + 6], data[i + 7]);
}
}

View file

@ -0,0 +1,104 @@
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.PartPose;
import net.minecraft.client.model.geom.builders.CubeListBuilder;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.MeshDefinition;
import net.minecraft.client.model.geom.builders.PartDefinition;
import net.minecraft.client.model.player.PlayerModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.resources.Identifier;
/**
* The rabbit: a box model drawn in place of the player. It does not animate itself: the player model it
* replaces is posed by the game and those angles are copied onto the matching parts, so the rabbit walks,
* swings and looks around exactly as the player would. One instance per player, because a submitted model is
* only read later and two players sharing one would both come out in the last pose set.
*/
public final class RabbitModel {
private static final Identifier TEXTURE = Identifier.fromNamespaceAndPath("lovisual", "models/rabbit.png");
/** The arms sit at a slight angle of their own, which the copied pose is added onto. */
private static final float ARM_TILT = 0.0873f;
private final ModelPart root;
private final ModelPart body;
private final ModelPart head;
private final ModelPart leftArm;
private final ModelPart rightArm;
private final ModelPart leftLeg;
private final ModelPart rightLeg;
public RabbitModel() {
MeshDefinition mesh = new MeshDefinition();
PartDefinition rabbit = mesh.getRoot().addOrReplaceChild("rabbit",
box(28, 45, -5.0f, -13.0f, -5.0f, 10.0f, 11.0f, 8.0f), PartPose.offset(0.0f, 24.0f, 0.0f));
rabbit.addOrReplaceChild("right_leg", box(0, 32, -2.0f, 0.0f, -2.0f, 4.0f, 2.0f, 4.0f),
PartPose.offset(-3.0f, -2.0f, -1.0f));
rabbit.addOrReplaceChild("left_leg", box(0, 32, -2.0f, 0.0f, -2.0f, 4.0f, 2.0f, 4.0f),
PartPose.offset(3.0f, -2.0f, -1.0f));
rabbit.addOrReplaceChild("left_arm", box(24, 16, 0.0f, 0.0f, -2.0f, 2.0f, 8.0f, 4.0f),
PartPose.offsetAndRotation(5.0f, -13.0f, -1.0f, 0.0f, 0.0f, -ARM_TILT));
rabbit.addOrReplaceChild("right_arm", box(24, 16, -2.0f, 0.0f, -2.0f, 2.0f, 8.0f, 4.0f),
PartPose.offsetAndRotation(-5.0f, -13.0f, -1.0f, 0.0f, 0.0f, ARM_TILT));
// the face is the head's own boxes: ears, cheeks and the muzzle
rabbit.addOrReplaceChild("head", CubeListBuilder.create()
.texOffs(0, 0).addBox(-3.0f, 0.0f, -4.0f, 6.0f, 1.0f, 6.0f)
.texOffs(0, 45).addBox(-4.0f, -11.0f, -4.0f, 8.0f, 11.0f, 8.0f)
.texOffs(56, 0).addBox(-5.0f, -9.0f, -5.0f, 2.0f, 3.0f, 2.0f)
.texOffs(56, 0).addBox(3.0f, -9.0f, -5.0f, 2.0f, 3.0f, 2.0f)
.texOffs(46, 0).addBox(1.0f, -20.0f, 0.0f, 3.0f, 9.0f, 1.0f)
.texOffs(46, 0).addBox(-4.0f, -20.0f, 0.0f, 3.0f, 9.0f, 1.0f),
PartPose.offset(0.0f, -14.0f, -1.0f));
root = LayerDefinition.create(mesh, 64, 64).bakeRoot();
body = root.getChild("rabbit");
head = body.getChild("head");
leftArm = body.getChild("left_arm");
rightArm = body.getChild("right_arm");
leftLeg = body.getChild("left_leg");
rightLeg = body.getChild("right_leg");
}
/** Draws the rabbit in the pose the player model is in this frame. */
public void submit(PlayerModel vanilla, AvatarRenderState state, PoseStack pose, SubmitNodeCollector collector,
int light, int overlay, int color, int outlineColor) {
// the game poses the player model only after handing it over, so its angles are still last frame's
vanilla.setupAnim(state);
copyAngles(vanilla);
pose.pushPose();
pose.scale(1.25f, 1.25f, 1.25f);
pose.translate(0.0f, -0.3f, 0.0f);
collector.submitModelPart(root, pose, RenderTypes.entityTranslucent(TEXTURE), light, overlay,
(TextureAtlasSprite) null, color, (ModelFeatureRenderer.CrumblingOverlay) null, outlineColor);
pose.popPose();
}
private void copyAngles(PlayerModel vanilla) {
copy(vanilla.head, head, 0.0f);
// the body stays upright instead of following the player's own body rotation, which would tip it over
body.xRot = 0.0f;
body.yRot = 0.0f;
body.zRot = 0.0f;
copy(vanilla.leftArm, leftArm, -ARM_TILT);
copy(vanilla.rightArm, rightArm, ARM_TILT);
copy(vanilla.leftLeg, leftLeg, 0.0f);
copy(vanilla.rightLeg, rightLeg, 0.0f);
}
private static void copy(ModelPart from, ModelPart to, float zTilt) {
to.xRot = from.xRot;
to.yRot = from.yRot;
to.zRot = from.zRot + zTilt;
}
private static CubeListBuilder box(int u, int v, float x, float y, float z, float width, float height, float depth) {
return CubeListBuilder.create().texOffs(u, v).addBox(x, y, z, width, height, depth);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.lovisual.features.module.modules.visuals.models.mesh;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.features.module.modules.visuals.models.mesh.blockbench.BbModelParser;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* Verity: a free-form ball mesh read from its Blockbench file on first use, with the face painted on the
* texture (the avatar ships one texture per mood). Drawn floating at the player's position.
*/
public final class VerityMesh {
private static final String MODEL = "/assets/lovisual/models/verity/verity.bbmodel";
/** Where the ball sits on the player, in entity model space (y grows downwards). */
private static final float CENTER_Y = 0.75f;
/** Blockbench units are sixteenths of a block; this also gives the ball a player's presence. */
private static final float UNIT = 2.2f / 16.0f;
private float[] triangles;
public void submit(PoseStack pose, SubmitNodeCollector collector, int light, int overlay,
Identifier texture, float scale) {
float[] data = geometry();
if (data.length == 0) return;
pose.pushPose();
pose.translate(0.0f, CENTER_Y, 0.0f);
pose.scale(scale, scale, scale);
MeshVertices.submit(pose, collector, texture, data, light, overlay);
pose.popPose();
}
private float[] geometry() {
if (triangles == null) triangles = load();
return triangles;
}
private static float[] load() {
try (InputStream stream = VerityMesh.class.getResourceAsStream(MODEL)) {
if (stream == null) return new float[0];
return BbModelParser.parse(new String(stream.readAllBytes(), StandardCharsets.UTF_8), UNIT);
} catch (IOException | RuntimeException e) {
return new float[0];
}
}
}

View file

@ -0,0 +1,133 @@
package dev.loki.lovisual.features.module.modules.visuals.models.mesh.blockbench;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.Map;
/**
* Reads the free-form mesh elements of a Blockbench {@code .bbmodel} into triangles (position, uv, normal per
* vertex). Each element's own rotation about its origin is baked in, and the result is moved into entity model
* space (y grows downwards) at {@code unit} blocks per Blockbench unit.
*/
public final class BbModelParser {
private static final float DEFAULT_UV_SIZE = 64.0f;
private static final int FLOATS_PER_VERTEX = 8;
private BbModelParser() {
}
public static float[] parse(String json, float unit) {
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
float uvWidth = DEFAULT_UV_SIZE;
float uvHeight = DEFAULT_UV_SIZE;
if (root.has("resolution")) {
JsonObject resolution = root.getAsJsonObject("resolution");
uvWidth = resolution.get("width").getAsFloat();
uvHeight = resolution.get("height").getAsFloat();
}
FloatList out = new FloatList();
for (JsonElement entry : root.getAsJsonArray("elements")) {
JsonObject element = entry.getAsJsonObject();
if (!element.has("vertices") || !element.has("faces")) continue;
float[] origin = triple(element, "origin");
float[] rotation = triple(element, "rotation");
JsonObject vertices = element.getAsJsonObject("vertices");
for (Map.Entry<String, JsonElement> face : element.getAsJsonObject("faces").entrySet()) {
JsonObject data = face.getValue().getAsJsonObject();
JsonArray order = data.getAsJsonArray("vertices");
JsonObject uv = data.getAsJsonObject("uv");
// a quad is two triangles around its first corner; larger faces fan the same way
for (int corner = 2; corner < order.size(); corner++) {
String[] ids = {order.get(0).getAsString(), order.get(corner - 1).getAsString(), order.get(corner).getAsString()};
triangle(out, vertices, uv, origin, rotation, uvWidth, uvHeight, unit, ids);
}
}
}
return out.toArray();
}
private static void triangle(FloatList out, JsonObject vertices, JsonObject uv, float[] origin, float[] rotation,
float uvWidth, float uvHeight, float unit, String[] ids) {
float[][] points = new float[3][];
for (int i = 0; i < 3; i++) {
points[i] = point(vertices, origin, rotation, ids[i], unit);
if (points[i] == null) return;
}
float[] normal = normal(points[0], points[1], points[2]);
for (int i = 0; i < 3; i++) {
float[] texture = uvOf(uv, ids[i], uvWidth, uvHeight);
out.add(points[i][0]).add(points[i][1]).add(points[i][2]);
out.add(texture[0]).add(texture[1]);
out.add(normal[0]).add(normal[1]).add(normal[2]);
}
}
private static float[] point(JsonObject vertices, float[] origin, float[] rotation, String id, float unit) {
if (!vertices.has(id)) return null;
JsonArray raw = vertices.getAsJsonArray(id);
float[] rotated = rotate(raw.get(0).getAsFloat() - origin[0], raw.get(1).getAsFloat() - origin[1],
raw.get(2).getAsFloat() - origin[2], rotation);
// Blockbench has y growing upwards, entity model space has it growing down
return new float[]{(rotated[0] + origin[0]) * unit, -(rotated[1] + origin[1]) * unit, (rotated[2] + origin[2]) * unit};
}
/** X, then Y, then Z: the order Blockbench applies an element's own rotation in. */
static float[] rotate(float x, float y, float z, float[] rotationDegrees) {
double rx = Math.toRadians(rotationDegrees[0]);
double ry = Math.toRadians(rotationDegrees[1]);
double rz = Math.toRadians(rotationDegrees[2]);
float y1 = (float) (y * Math.cos(rx) - z * Math.sin(rx));
float z1 = (float) (y * Math.sin(rx) + z * Math.cos(rx));
float x2 = (float) (x * Math.cos(ry) + z1 * Math.sin(ry));
float z2 = (float) (-x * Math.sin(ry) + z1 * Math.cos(ry));
float x3 = (float) (x2 * Math.cos(rz) - y1 * Math.sin(rz));
float y3 = (float) (x2 * Math.sin(rz) + y1 * Math.cos(rz));
return new float[]{x3, y3, z2};
}
private static float[] uvOf(JsonObject uv, String id, float uvWidth, float uvHeight) {
if (uv == null || !uv.has(id)) return new float[]{0.0f, 0.0f};
JsonArray raw = uv.getAsJsonArray(id);
return new float[]{raw.get(0).getAsFloat() / uvWidth, raw.get(1).getAsFloat() / uvHeight};
}
static float[] normal(float[] a, float[] b, float[] c) {
float ux = b[0] - a[0];
float uy = b[1] - a[1];
float uz = b[2] - a[2];
float vx = c[0] - a[0];
float vy = c[1] - a[1];
float vz = c[2] - a[2];
float nx = uy * vz - uz * vy;
float ny = uz * vx - ux * vz;
float nz = ux * vy - uy * vx;
float length = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
if (length < 1.0e-6f) return new float[]{0.0f, -1.0f, 0.0f};
return new float[]{nx / length, ny / length, nz / length};
}
private static float[] triple(JsonObject element, String key) {
if (!element.has(key)) return new float[]{0.0f, 0.0f, 0.0f};
JsonArray raw = element.getAsJsonArray(key);
return new float[]{raw.get(0).getAsFloat(), raw.get(1).getAsFloat(), raw.get(2).getAsFloat()};
}
/** Growable float buffer, so a thousand-triangle mesh is not boxed into Float objects. */
private static final class FloatList {
private float[] data = new float[FLOATS_PER_VERTEX * 3 * 256];
private int size;
FloatList add(float value) {
if (size == data.length) data = java.util.Arrays.copyOf(data, size * 2);
data[size++] = value;
return this;
}
float[] toArray() {
return java.util.Arrays.copyOf(data, size);
}
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.DoublePlantBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.ChestType;
/**
* Blocks you place slide in from your hand, tilting and growing into position. The block is hidden from the
* chunk mesh for the duration and drawn as a moving block instead (hooks: BlockItemPlaceMixin and friends).
*/
@ModuleInfo(
id = "placeanimation",
displayName = "Place Animation",
aliases = {"placeanimation"},
category = ModuleCategory.VISUALS
)
public class PlaceAnimation extends Module {
private final NumberValue<Integer> duration = num("placeAnimationDuration", "duration", 3, 1, 12);
private final NumberValue<Float> slide = num("placeAnimationSlide", "slide", 1.0f, 0.0f, 4.0f);
private final NumberValue<Float> tilt = num("placeAnimationTilt", "tilt", 6.0f, 0.0f, 45.0f);
private final NumberValue<Float> startScale = num("placeAnimationStartScale", "start_scale", 1.0f, 0.1f, 1.0f);
private final BooleanValue blockEntities = bool("placeAnimationBlockEntities", "block_entities", false);
private final PlaceAnimations animations = new PlaceAnimations();
@Override
public void onTick() {
ClientLevel level = Minecraft.getInstance().level;
if (level != null) animations.tick(level);
}
@Override
public void onDisable() {
animations.clear();
}
/** Called when the local player has just placed {@code state} at {@code pos}. */
public void onBlockPlaced(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand) {
if (!isEnabled() || player == null || !isAnimatable(level, pos, state)) return;
PlacedBlock.Params params = new PlacedBlock.Params(duration.get(), slide.get(),
(float) Math.toRadians(tilt.get()), startScale.get());
animations.start(level, pos, state, player, hand, params, blockEntities.get());
}
public boolean isHidden(BlockPos pos) {
return animations.isHidden(pos);
}
public void submit(PoseStack stack, SubmitNodeCollector collector, float partial) {
if (isEnabled()) animations.submit(stack, collector, partial);
}
public void applyBlockEntity(BlockPos pos, PoseStack stack, float partial) {
if (isEnabled()) animations.applyBlockEntity(pos, stack, partial);
}
/** Multi-block structures and non-solid blocks animate badly, so they are left alone. */
private static boolean isAnimatable(ClientLevel level, BlockPos pos, BlockState state) {
if (state.is(BlockTags.BEDS) || state.getBlock() instanceof DoorBlock || state.getBlock() instanceof DoublePlantBlock) {
return false;
}
if (state.hasProperty(ChestBlock.TYPE) && state.getValue(ChestBlock.TYPE) != ChestType.SINGLE) return false;
return !state.getCollisionShape(level, pos).isEmpty();
}
}

View file

@ -0,0 +1,97 @@
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.core.BlockPos;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Active place animations plus the set of positions the chunk mesher must skip. The hidden set is read
* from chunk-compile threads, so it is concurrent; everything else stays on the client thread.
*/
final class PlaceAnimations {
private static final double MAX_DISTANCE_SQR = 1024.0;
private static final int REBUILD_GRACE_TICKS = 2;
private static final int REBUILD_FLAGS = 10;
private final Map<BlockPos, PlacedBlock> active = new HashMap<>();
private final Set<BlockPos> hidden = ConcurrentHashMap.newKeySet();
void start(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand,
PlacedBlock.Params params, boolean animateBlockEntities) {
boolean model = state.getRenderShape() == RenderShape.MODEL;
if (!model && !animateBlockEntities) return;
if (pos.distToCenterSqr(Minecraft.getInstance().gameRenderer.mainCamera().position()) > MAX_DISTANCE_SQR) return;
BlockPos key = pos.immutable();
active.put(key, new PlacedBlock(level, key, state, player, hand, params));
if (model) {
hidden.add(key);
rebuild(level, key);
}
}
boolean isHidden(BlockPos pos) {
return !hidden.isEmpty() && hidden.contains(pos);
}
void tick(ClientLevel level) {
if (active.isEmpty()) return;
for (Iterator<PlacedBlock> it = active.values().iterator(); it.hasNext(); ) {
PlacedBlock block = it.next();
if (block.level() != level) continue;
if (level.getBlockState(block.pos()) != block.state()) {
reveal(level, block.pos());
it.remove();
continue;
}
block.tick();
if (block.finished()) reveal(level, block.pos());
if (block.expired(REBUILD_GRACE_TICKS)) it.remove();
}
}
void submit(PoseStack stack, SubmitNodeCollector collector, float partial) {
if (active.isEmpty()) return;
Vec3 camera = Minecraft.getInstance().gameRenderer.mainCamera().position();
for (PlacedBlock block : active.values()) {
if (block.rendersModel()) block.submit(stack, collector, camera, partial);
}
}
void applyBlockEntity(BlockPos pos, PoseStack stack, float partial) {
if (active.isEmpty() || pos == null) return;
PlacedBlock block = active.get(pos);
if (block != null) block.apply(stack, partial);
}
void clear() {
ClientLevel level = Minecraft.getInstance().level;
for (PlacedBlock block : active.values()) {
if (level != null && block.level() == level) reveal(level, block.pos());
}
active.clear();
hidden.clear();
}
private void reveal(ClientLevel level, BlockPos pos) {
if (hidden.remove(pos)) rebuild(level, pos);
}
private static void rebuild(ClientLevel level, BlockPos pos) {
BlockState state = level.getBlockState(pos);
level.sendBlockUpdated(pos, state, state, REBUILD_FLAGS);
}
}

View file

@ -0,0 +1,75 @@
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
import net.minecraft.core.Direction;
import org.joml.Vector3f;
import java.util.Set;
/** Pure maths of the place animation: easing curves and the hand-side slide direction. */
public final class PlaceMotion {
public static final float FINAL_SCALE = 0.999f;
private static final float SIDE_SHIFT = 0.1f;
private static final float FORWARD_SHIFT = 0.2f;
private static final float VERTICAL_SHIFT = 0.2f;
private static final float SLIDE_BASE = easeBase(0.9f);
private static final float TILT_BASE = easeBase(-0.08f);
private static final float SCALE_BASE = easeBase(-0.7f);
private PlaceMotion() {
}
/** Animation progress 0..1; the extra tick of length lets the last frame land exactly. */
public static float progress(int age, float partial, int lifetime) {
return Math.min(1.0f, (age + partial) / (lifetime + 1));
}
/** How far the block still is from its place: 1 at the start, 0 when landed (accelerates into place). */
public static float slideRemaining(float progress) {
return 1.0f - ease(progress, SLIDE_BASE);
}
/** Tilt in radians: starts at {@code maxTilt} and settles quickly. */
public static float tilt(float progress, float maxTilt) {
return (1.0f - ease(progress, TILT_BASE)) * maxTilt;
}
/** Block scale from {@code start} up to a hair under a full block (avoids z-fighting with the mesh). */
public static float scale(float start, float progress) {
return start + (FINAL_SCALE - start) * ease(progress, SCALE_BASE);
}
/**
* Offset the block starts from, in world axes: back towards the player, to the hand's side, and up or
* down depending on where the player looks. {@code rightSide} is the side of the hand that placed it.
*/
public static Vector3f slideStart(float yawDegrees, float pitchDegrees, boolean rightSide, float distance) {
double yaw = Math.toRadians(yawDegrees);
float backX = (float) Math.sin(yaw);
float backZ = (float) -Math.cos(yaw);
float side = rightSide ? 1.0f : -1.0f;
float vertical = pitchDegrees > 0.0f ? VERTICAL_SHIFT : -VERTICAL_SHIFT;
return new Vector3f(
(backZ * side * SIDE_SHIFT + backX * FORWARD_SHIFT) * distance,
vertical * distance,
(-backX * side * SIDE_SHIFT + backZ * FORWARD_SHIFT) * distance);
}
/** Drops the components that would push the block into a blocked neighbouring side. */
public static Vector3f restrict(Vector3f offset, Set<Direction> blocked) {
Vector3f out = new Vector3f(offset);
if (out.x > 0.0f && blocked.contains(Direction.EAST) || out.x < 0.0f && blocked.contains(Direction.WEST)) out.x = 0.0f;
if (out.y > 0.0f && blocked.contains(Direction.UP) || out.y < 0.0f && blocked.contains(Direction.DOWN)) out.y = 0.0f;
if (out.z > 0.0f && blocked.contains(Direction.SOUTH) || out.z < 0.0f && blocked.contains(Direction.NORTH)) out.z = 0.0f;
return out;
}
/** Exponential ease through (0,0) and (1,1); positive bases accelerate, negative bases decelerate. */
static float ease(float t, float base) {
return (float) (base * Math.pow(1.0 / base + 1.0, t) - base);
}
static float easeBase(float k) {
return k > 0.0f ? (float) -Math.log(k) : (float) (Math.log(-k) - 1.0);
}
}

View file

@ -0,0 +1,150 @@
package dev.loki.lovisual.features.module.modules.visuals.placeanimation;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.block.MovingBlockRenderState;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.EnumSet;
/** One block sliding, tilting and scaling into its place; drawn as a moving block while the mesh hides it. */
final class PlacedBlock {
/** Animation settings captured at placement time. */
record Params(int ticks, float slide, float tiltRadians, float startScale) {
}
private static final float EPSILON = 1.0E-6f;
private static final double NEAR_EDGE = 0.25;
private static final double FAR_EDGE = 0.75;
private final ClientLevel level;
private final BlockPos pos;
private final BlockState state;
private final boolean rendersModel;
private final Params params;
private final Vec3 offset;
private final float yaw;
private final float slideY;
private final float slideZ;
private final MovingBlockRenderState renderState = new MovingBlockRenderState();
private int age;
PlacedBlock(ClientLevel level, BlockPos pos, BlockState state, Player player, InteractionHand hand, Params params) {
this.level = level;
this.pos = pos;
this.state = state;
this.rendersModel = state.getRenderShape() == RenderShape.MODEL;
this.params = params;
this.offset = state.getOffset(pos);
boolean rightHand = (player.getMainArm() == HumanoidArm.RIGHT) == (hand == InteractionHand.MAIN_HAND);
Vector3f start = PlaceMotion.slideStart(player.getYRot(), player.getXRot(), rightHand, params.slide());
float length = start.length();
Vector3f free = restrictToFreeNeighbours(player, start);
float freeLength = free.length();
if (length < EPSILON || freeLength < EPSILON) {
yaw = 0.0f;
slideY = 0.0f;
slideZ = 0.0f;
} else {
free.div(freeLength);
yaw = (float) Math.atan2(free.x, free.z);
slideY = free.y * length;
slideZ = (float) (free.z * Math.cos(-yaw) - free.x * Math.sin(-yaw)) * length;
}
renderState.blockState = state;
renderState.blockPos = pos;
renderState.randomSeedPos = pos;
renderState.biome = level.getBiome(pos);
renderState.cardinalLighting = level.cardinalLighting();
renderState.lightEngine = level.getLightEngine();
}
ClientLevel level() {
return level;
}
BlockPos pos() {
return pos;
}
BlockState state() {
return state;
}
boolean rendersModel() {
return rendersModel;
}
void tick() {
age++;
}
boolean finished() {
return age >= params.ticks();
}
boolean expired(int graceTicks) {
return age >= params.ticks() + graceTicks;
}
void submit(PoseStack stack, SubmitNodeCollector collector, Vec3 camera, float partial) {
stack.pushPose();
stack.translate(pos.getX() - camera.x, pos.getY() - camera.y, pos.getZ() - camera.z);
apply(stack, partial);
collector.submitMovingBlock(stack, renderState, 0);
stack.popPose();
}
/** Transforms a pose stack positioned at the block's corner into the animated pose. */
void apply(PoseStack stack, float partial) {
float progress = PlaceMotion.progress(age, partial, params.ticks());
float remaining = PlaceMotion.slideRemaining(progress);
float scale = PlaceMotion.scale(params.startScale(), progress);
float tilt = PlaceMotion.tilt(progress, params.tiltRadians());
stack.translate(0.5 + offset.x, 0.5 + offset.y, 0.5 + offset.z);
stack.mulPose(new Quaternionf().rotationY(yaw));
stack.translate(0.0f, slideY * remaining, slideZ * remaining);
stack.translate(0.0f, -0.5f, 0.0f);
stack.mulPose(new Quaternionf().rotationX(tilt));
stack.translate(0.0f, 0.5f, 0.0f);
stack.scale(scale, scale, scale);
stack.mulPose(new Quaternionf().rotationY(-yaw));
stack.translate(-0.5 - offset.x, -0.5 - offset.y, -0.5 - offset.z);
}
/** Keeps the slide from pushing the block through a solid neighbour on the side it would move towards. */
private Vector3f restrictToFreeNeighbours(Player player, Vector3f start) {
EnumSet<Direction> blocked = EnumSet.noneOf(Direction.class);
BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos();
for (Direction direction : Direction.values()) {
probe.setWithOffset(pos, direction);
VoxelShape shape = level.getBlockState(probe).getCollisionShape(level, probe);
if (shape.isEmpty()) continue;
Direction.Axis axis = direction.getAxis();
boolean touches = direction.getAxisDirection() == Direction.AxisDirection.POSITIVE
? shape.min(axis) <= NEAR_EDGE
: shape.max(axis) >= FAR_EDGE;
if (touches) blocked.add(direction);
}
Vector3f free = PlaceMotion.restrict(start, blocked);
if (free.lengthSquared() >= EPSILON || blocked.isEmpty()) return free;
for (Direction direction : Direction.orderedByNearest(player)) {
if (!blocked.contains(direction)) return new Vector3f(direction.getStepX(), direction.getStepY(), direction.getStepZ());
}
return free;
}
}

View file

@ -0,0 +1,92 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.platform.presence.PresenceClient;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.world.entity.player.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Shows other LoVisual users' open ClickGUI as a small mirror held in front of them, and shares your own cursor the
* same way. Needs a linked LoVisual account (device token) and a multiplayer server; data goes through the
* chat-service presence endpoint and contains only the cursor position and a few menu flags.
*/
@ModuleInfo(
id = "guipresence",
displayName = "GuiPresence",
category = ModuleCategory.VISUALS,
description = "Shows the ClickGUI other LoVisual users have open, and shares yours"
)
public class GuiPresence extends Module {
private static final float FADE_RANGE = 6.0f;
private static final int MAX_WANT = 40;
private final PresenceClient client = new PresenceClient();
private final PresenceSampler sampler = new PresenceSampler();
private final PresencePanelRenderer panel = new PresencePanelRenderer();
private final BooleanValue share = bool("guiPresenceShare", "share", true);
private final BooleanValue showOthers = bool("guiPresenceShowOthers", "show_others", true);
private final NumberValue<Integer> distance = visibleWhen(num("guiPresenceDistance", "distance", 32, 8, 64), showOthers::get);
private final NumberValue<Float> scale = visibleWhen(num("guiPresenceScale", "scale", 1.0f, 0.5f, 2.0f), showOthers::get);
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
client.clear();
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
ServerData server = mc.getCurrentServer();
if (!isEnabled() || mc.level == null || mc.player == null || server == null) return;
if (!share.get() && !showOthers.get()) return;
long now = System.currentTimeMillis();
boolean open = share.get() && sampler.sample(now);
if (!client.due(now, open)) return;
client.sync(now, server.ip, mc.player.getUUID(), open ? sampler.drain() : null, share.get(), wanted(mc));
}
private List<UUID> wanted(Minecraft mc) {
List<UUID> want = new ArrayList<>();
if (!showOthers.get()) return want;
double max = distance.get() + FADE_RANGE;
for (Player p : mc.level.players()) {
if (p == mc.player || mc.player.distanceTo(p) > max) continue;
want.add(p.getUUID());
if (want.size() >= MAX_WANT) break;
}
return want;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || !showOthers.get() || mc.level == null || mc.player == null) return;
long now = System.currentTimeMillis();
for (Map.Entry<UUID, PresenceTimeline> e : client.remote().entrySet()) {
Player p = mc.level.getPlayerByUUID(e.getKey());
if (p == null || p == mc.player || !e.getValue().live(now)) continue;
float d = mc.player.distanceTo(p);
float fade = Math.min(1.0f, (distance.get() - d) / FADE_RANGE + 1.0f);
panel.draw(renderer, p, tickDelta, e.getValue(), now, scale.get(), Math.max(0.0f, fade));
}
}
}

View file

@ -0,0 +1,128 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
/**
* Draws a remote player's ClickGUI as a small flat mirror held in front of them: four columns, the one under their
* cursor lit, the row under the cursor highlighted, the cursor itself and a ring while the button is pressed.
* Built from coloured quads in a plane spanned by the player's facing, so it needs no text or textures.
*/
final class PresencePanelRenderer {
private static final float ASPECT = 65.0f / 104.0f;
private static final float FORWARD = 0.85f;
private static final float HEIGHT_RATIO = 0.6f;
private static final int COLUMNS = 4;
private static final int ROWS = 7;
private static final float GAP = 0.018f;
private static final float LAYER = 0.0025f;
private final float[] a = new float[3];
private final float[] up = {0.0f, 1.0f, 0.0f};
private final float[] f = new float[3];
private final float[] c = new float[3];
private float width;
private float height;
void draw(Renderer3D renderer, Player player, float tickDelta, PresenceTimeline timeline, long now,
float scale, float alpha) {
PresenceCodec.Gui gui = timeline.meta();
PresenceTimeline.Point cursor = timeline.at(now);
if (gui == null || gui.closed() || cursor == null || alpha <= 0.01f) return;
MeshBuilder m = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (m == null) return;
place(player, tickDelta, scale);
int accent = Theme.theme().accent() & 0x00FFFFFF;
int bg = Theme.theme().windowBg() & 0x00FFFFFF;
quad(m, 0.0f, 0.0f, 1.0f, 1.0f, 0, rgba(bg, 0.82f * alpha));
float colW = (1.0f - GAP * (COLUMNS + 1)) / COLUMNS;
float cx = cursor.x() / 255.0f;
float cy = cursor.y() / 255.0f;
for (int i = 0; i < COLUMNS; i++) {
float x0 = GAP + i * (colW + GAP);
boolean active = i == gui.category();
quad(m, x0, 0.04f, x0 + colW, 0.96f, 1, rgba(active ? accent : bg, (active ? 0.28f : 0.5f) * alpha));
quad(m, x0, 0.04f, x0 + colW, 0.13f, 2, rgba(accent, (active ? 0.7f : 0.3f) * alpha));
rows(m, i, x0, colW, active, gui, cy, accent, alpha);
}
if (gui.searching()) quad(m, 0.3f, -0.1f, 0.7f, -0.03f, 2, rgba(accent, 0.85f * alpha));
cursor(m, cx, cy, cursor.pressed(), accent, alpha);
}
private void rows(MeshBuilder m, int column, float x0, float colW, boolean active, PresenceCodec.Gui gui,
float cy, int accent, float alpha) {
float top = 0.17f;
float step = (0.95f - top) / ROWS;
int hot = active && !gui.settings() ? (int) Math.floor((cy - top) / step) : -1;
for (int r = 0; r < ROWS; r++) {
float y0 = top + r * step;
float w = active && gui.settings() ? colW * (0.35f + 0.55f * (((r * 37 + column * 11) % 10) / 10.0f)) : colW * 0.82f;
boolean lit = r == hot && gui.moduleIndex() != PresenceCodec.NO_MODULE;
quad(m, x0 + colW * 0.09f, y0, x0 + colW * 0.09f + w, y0 + step * 0.7f, 2,
rgba(lit ? accent : 0xFFFFFF, (lit ? 0.75f : 0.1f) * alpha));
}
}
private void cursor(MeshBuilder m, float cx, float cy, boolean pressed, int accent, float alpha) {
float s = 0.014f;
quad(m, cx - s, cy - s, cx + s, cy + s, 4, rgba(0xFFFFFF, alpha));
if (!pressed) return;
float r = 0.035f;
float t = 0.006f;
int col = rgba(accent, 0.9f * alpha);
quad(m, cx - r, cy - r, cx + r, cy - r + t, 3, col);
quad(m, cx - r, cy + r - t, cx + r, cy + r, 3, col);
quad(m, cx - r, cy - r, cx - r + t, cy + r, 3, col);
quad(m, cx + r - t, cy - r, cx + r, cy + r, 3, col);
}
private void place(Player p, float tickDelta, float scale) {
double yaw = Math.toRadians(p.yBodyRot);
a[0] = (float) Math.cos(yaw);
a[1] = 0.0f;
a[2] = (float) Math.sin(yaw);
f[0] = (float) -Math.sin(yaw);
f[1] = 0.0f;
f[2] = (float) Math.cos(yaw);
Vec3 pos = p.getPosition(tickDelta);
float h = p.getBbHeight() * HEIGHT_RATIO;
c[0] = (float) pos.x + f[0] * FORWARD;
c[1] = (float) pos.y + h;
c[2] = (float) pos.z + f[2] * FORWARD;
width = scale;
height = scale * ASPECT;
}
/** Quad in panel space: u to the right, v downward, both 0..1; {@code layer} lifts it toward the viewer. */
private void quad(MeshBuilder m, float u0, float v0, float u1, float v1, int layer, int argb) {
m.ensureQuadCapacity();
RenderColor col = new RenderColor(argb);
int i1 = vertex(m, u0, v0, layer, col);
int i2 = vertex(m, u1, v0, layer, col);
int i3 = vertex(m, u1, v1, layer, col);
int i4 = vertex(m, u0, v1, layer, col);
m.quad(i1, i2, i3, i4);
}
private int vertex(MeshBuilder m, float u, float v, int layer, RenderColor col) {
float du = (u - 0.5f) * width;
float dv = (0.5f - v) * height;
float off = layer * LAYER;
return m.vec3(c[0] + a[0] * du + up[0] * dv + f[0] * off,
c[1] + a[1] * du + up[1] * dv + f[1] * off,
c[2] + a[2] * du + up[2] * dv + f[2] * off).color(col).next();
}
private static int rgba(int rgb, float alpha) {
int al = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
return (al << 24) | (rgb & 0x00FFFFFF);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.ModulesMenuScreen;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import net.minecraft.client.Minecraft;
import org.lwjgl.glfw.GLFW;
import java.util.ArrayList;
import java.util.List;
/** Samples the local ClickGUI cursor every 100 ms into a short queue that is flushed with the next sync. */
final class PresenceSampler {
private final List<PresenceCodec.Sample> queue = new ArrayList<>();
private MenuPresence last;
private long lastSample;
/** @return true while a menu is open (and sampled) */
boolean sample(long now) {
Minecraft mc = Minecraft.getInstance();
boolean down = mc.getWindow() != null
&& GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
MenuPresence snap = ModulesMenuScreen.presenceSnapshot(down);
if (snap == null) {
last = null;
queue.clear();
return false;
}
last = snap;
if (now - lastSample >= PresenceTimeline.SAMPLE_MS) {
lastSample = now;
queue.add(new PresenceCodec.Sample(Math.round(snap.x() * 255.0f), Math.round(snap.y() * 255.0f),
snap.pressed(), snap.scroll()));
while (queue.size() > PresenceCodec.MAX_SAMPLES) queue.remove(0);
}
return true;
}
/** Payload for the current menu state with the queued samples, or null when no menu is open. */
PresenceCodec.Gui drain() {
if (last == null) return null;
PresenceCodec.Gui gui = new PresenceCodec.Gui(last.category(), last.settingsOpen(), last.searching(), false,
last.moduleIndex(), new ArrayList<>(queue));
queue.clear();
return gui;
}
}

View file

@ -260,14 +260,16 @@ public class SpatialGui extends Module {
cachedSampler = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR);
}
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, textureView, cachedSampler, Renderer3D.DepthMode.NONE);
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_PREMULTIPLIED, textureView, cachedSampler, Renderer3D.DepthMode.NONE);
if (mesh != null) {
// The capture holds premultiplied colour (GUI draws blended onto a transparent target), so the fade
// factor must scale RGB together with alpha.
int a = (int) (drawAlpha * 255.0F);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(255, 255, 255, a).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(255, 255, 255, a).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(255, 255, 255, a).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(255, 255, 255, a).next();
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(a, a, a, a).next();
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(a, a, a, a).next();
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(a, a, a, a).next();
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(a, a, a, a).next();
mesh.quad(i1, i2, i3, i4);
}

View file

@ -0,0 +1,76 @@
package dev.loki.lovisual.features.module.modules.visuals.totems;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Totems of Undying in your inventory, drawn next to the hotbar; red when running low. */
@ModuleInfo(
id = "totemcounter",
displayName = "Totem Counter",
aliases = {"totemcounter", "totems"},
category = ModuleCategory.VISUALS
)
public class TotemCounter extends Module {
private static final int WHITE = 0xFFFFFFFF;
private static final int LOW = 0xFFFF5555;
private final BooleanValue showZero = bool("totemCounterShowZero", "show_zero", true);
private final NumberValue<Integer> lowAt = num("totemCounterLowAt", "low_at", 2, 0, 10);
private final NumberValue<Integer> offsetX = num("totemCounterOffsetX", "offset_x", 0, -300, 300);
private final NumberValue<Integer> offsetY = num("totemCounterOffsetY", "offset_y", 0, -300, 300);
private ItemStack icon;
@Override
public HudPhase getHudPhase() {
return HudPhase.AFTER_SUBTITLES;
}
@Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
GuiGraphicsExtractor ctx, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null) return;
int count = count(mc.player.getInventory());
String label = label(count, showZero.get());
if (label == null) return;
if (icon == null) icon = new ItemStack(Items.TOTEM_OF_UNDYING);
int x = ctx.guiWidth() / 2 + 98 + offsetX.get();
int y = ctx.guiHeight() - 20 + offsetY.get();
ctx.item(icon, x, y);
ctx.text(mc.font, label, x + 18, y + 4, color(count, lowAt.get()), true);
}
private static int count(Inventory inventory) {
int total = 0;
for (int i = 0; i < inventory.getContainerSize(); i++) {
ItemStack stack = inventory.getItem(i);
if (stack.is(Items.TOTEM_OF_UNDYING)) total += stack.getCount();
}
return total;
}
/** Text shown beside the icon, or null when nothing should be drawn. */
static String label(int count, boolean showZero) {
if (count <= 0 && !showZero) return null;
return "x" + Math.max(0, count);
}
/** Red once the stock is at or below the threshold (threshold 0 only flags an empty stock). */
static int color(int count, int lowAt) {
return count <= lowAt ? LOW : WHITE;
}
}

View file

@ -0,0 +1,173 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardFog;
import dev.loki.lovisual.features.module.modules.visuals.weather.blizzard.view.BlizzardOverlay;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.renderer.fog.FogData;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.FogType;
import net.minecraft.world.phys.Vec3;
/**
* Метель: снежинки со сносом в мире, белая дымка и обмерзание краёв экрана.
* Фасад: симуляция в {@link SnowFlakes}, 3D-рендер в {@link BlizzardRenderer},
* экранное покрытие в {@link BlizzardOverlay}.
*/
@ModuleInfo(
id = "blizzard",
displayName = "Blizzard",
category = ModuleCategory.VISUALS,
description = "High-altitude snowstorm with wind-driven flakes, whiteout fog and frosted edges"
)
public final class Blizzard extends Module {
private final NumberValue<Float> intensity = num("blizzardIntensity", "intensity", 0.85f, 0.1f, 1.0f);
private final NumberValue<Float> density = num("blizzardDensity", "density", 1.0f, 0.2f, 3.0f);
private final NumberValue<Float> wind = num("blizzardWind", "wind", 0.7f, 0.0f, 1.0f);
private final NumberValue<Float> turbulence = num("blizzardTurbulence", "turbulence", 0.6f, 0.0f, 1.0f);
private final NumberValue<Float> flakeSize = num("blizzardFlakeSize", "flake_size", 1.0f, 0.5f, 2.5f);
private final NumberValue<Float> radius = num("blizzardRadius", "radius", 12.0f, 6.0f, 24.0f);
private final NumberValue<Float> opacity = num("blizzardOpacity", "opacity", 0.8f, 0.1f, 1.0f);
private final BooleanValue whiteout = bool("blizzardWhiteout", "whiteout", true);
private final NumberValue<Float> visibility =
visibleWhen(num("blizzardVisibility", "visibility", 28.0f, 8.0f, 128.0f), whiteout::get);
private final BooleanValue frost = bool("blizzardFrost", "frost", true);
private final BooleanValue onlyWhenSnowing = bool("blizzardOnlyWhenSnowing", "only_when_snowing", false);
private final BooleanValue hideVanilla = bool("blizzardHideVanilla", "hide_vanilla", true);
private final RGBAColorValue tint = color("blizzardTint", "tint", "#FFFFFFFF");
private final SnowFlakes flakes = new SnowFlakes();
private long lastFrameNanos;
private float strength;
private float exposure;
private boolean frameActive;
@Override
public HudPhase getHudPhase() {
return HudPhase.AFTER_SUBTITLES;
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onEnable() {
reset();
}
@Override
public void onDisable() {
reset();
}
private void reset() {
lastFrameNanos = 0L;
strength = 0.0f;
exposure = 0.0f;
frameActive = false;
flakes.clear();
}
/** Ванильные полосы дождя и снега скрыты, пока идёт шторм и включена настройка. */
public boolean hidesVanillaWeather() {
return isEnabled() && frameActive && hideVanilla.get();
}
/** Штормовая пелена: сжимает туман и красит его в цвет снега (вызывается из миксина тумана). */
public void applyFog(Camera camera, FogData fog) {
if (!isEnabled() || !whiteout.get() || camera == null || fog == null) return;
FogType type = camera.getFluidInCamera();
if (type != FogType.NONE && type != FogType.ATMOSPHERIC) return;
float f = strength * exposure;
if (!BlizzardFog.active(f)) return;
float end = BlizzardFog.endDistance(visibility.get(), f);
float start = BlizzardFog.startDistance(visibility.get(), f);
fog.environmentalStart = BlizzardFog.limit(fog.environmentalStart, start);
fog.environmentalEnd = BlizzardFog.limit(fog.environmentalEnd, end);
fog.renderDistanceStart = BlizzardFog.limit(fog.renderDistanceStart, start);
fog.renderDistanceEnd = BlizzardFog.limit(fog.renderDistanceEnd, end);
fog.skyEnd = BlizzardFog.limit(fog.skyEnd, end * BlizzardFog.SKY_SHARE);
fog.cloudEnd = BlizzardFog.limit(fog.cloudEnd, end);
int argb = tint.getArgb();
float k = BlizzardFog.brightness(fog.color.x, fog.color.y, fog.color.z);
fog.color.x = BlizzardFog.blend(fog.color.x, ((argb >> 16) & 0xFF) / 255.0f, k, f);
fog.color.y = BlizzardFog.blend(fog.color.y, ((argb >> 8) & 0xFF) / 255.0f, k, f);
fog.color.z = BlizzardFog.blend(fog.color.z, (argb & 0xFF) / 255.0f, k, f);
}
private float frameDeltaSeconds() {
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
lastFrameNanos = now;
return Mth.clamp(dt, 0.0f, 0.1f);
}
private float targetStrength(Minecraft mc) {
float rain = mc.level.getRainLevel(1.0f);
BlockPos pos = mc.player.blockPosition();
boolean snowBiome = mc.level.getBiome(pos).value()
.getPrecipitationAt(pos, mc.level.getSeaLevel()) == Biome.Precipitation.SNOW;
return SnowFlakes.targetStrength(intensity.get(), onlyWhenSnowing.get(), rain, snowBiome);
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null
|| mc.gameRenderer.mainCamera() == null) {
frameActive = false;
return;
}
float dt = frameDeltaSeconds();
Vec3 cam = mc.gameRenderer.mainCamera().position();
ClientLevel level = mc.level;
SnowFlakes.Ground ground = (x, z) -> level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
strength = SnowFlakes.ramp(strength, targetStrength(mc), dt);
float open = SnowFlakes.exposureTarget(cam.y, ground.height(Mth.floor(cam.x), Mth.floor(cam.z)));
exposure = SnowFlakes.approach(exposure, open, SnowFlakes.EXPOSURE_PER_SECOND, dt);
frameActive = strength > 0.01f;
if (!frameActive && flakes.count == 0) return;
double seconds = (System.currentTimeMillis() % 600_000L) / 1000.0;
SnowFlakes.Wind windValue = SnowFlakes.wind(seconds, wind.get(), strength);
int target = frameActive ? SnowFlakes.targetCount(radius.get(), density.get(), strength) : 0;
flakes.update(dt, cam.x, cam.y, cam.z, radius.get(), windValue, SnowFlakes.turbulence(turbulence.get()),
target, seconds, ground);
if (flakes.count == 0) return;
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
BlizzardRenderer.render(mesh, flakes, RenderState.cameraRotation, cam.x, cam.y, cam.z,
tint.getArgb(), strength, intensity.get(), flakeSize.get(), opacity.get());
}
@Override
public void onRenderHudEngine(Renderer2D renderer, TextRenderer textRenderer,
GuiGraphicsExtractor ctx, float tickDelta) {
if (!isEnabled()) return;
float current = strength * exposure * intensity.get();
BlizzardOverlay.render(ctx, current, frost.get(), tint.getArgb());
}
}

View file

@ -0,0 +1,132 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Рендер снежинок по образцу evo: мягкая «капля» из двух воздушных змеев, вытянутая вдоль скорости,
* непрозрачный центр и прозрачные края. Снежинки позади камеры отбрасываются.
*/
final class BlizzardRenderer {
static final float FLAKE_RADIUS = 0.028f;
static final float MIN_ANGULAR_RADIUS = 0.0024f;
static final float STREAK_TIME = 0.022f;
static final float STREAK_FRONT_SHARE = 0.3f;
static final float FADE_IN_SECONDS = 0.45f;
static final float NEAR_FADE_START = 0.25f;
static final float NEAR_FADE_SPAN = 0.65f;
static final float BEHIND_DOT = -0.35f;
static final float BEHIND_MIN_DISTANCE = 3.0f;
private BlizzardRenderer() {
}
static void render(MeshBuilder mesh, SnowFlakes flakes, Quaternionf camRot, double camX, double camY, double camZ,
int argb, float strength, float intensity, float sizeSetting, float opacity) {
if (mesh == null || flakes.count == 0 || strength <= 0.001f || opacity <= 0.004f) return;
int rgb = argb & 0x00FFFFFF;
Vector3f forward = new Vector3f(0.0f, 0.0f, -1.0f).rotate(camRot);
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot);
for (int i = 0; i < flakes.count; i++) {
float px = (float) (flakes.x[i] - camX);
float py = (float) (flakes.y[i] - camY);
float pz = (float) (flakes.z[i] - camZ);
float dist = (float) Math.sqrt(px * px + py * py + pz * pz);
if (dist <= 0.0f) continue;
float vx = px / dist, vy = py / dist, vz = pz / dist;
if (isBehind(vx * forward.x + vy * forward.y + vz * forward.z, dist)) continue;
float alpha = flakeAlpha(dist, flakes.age[i], flakes.alpha[i], opacity, strength, intensity);
if (alpha <= 0.0f) continue;
float speed = (float) Math.sqrt(flakes.vx[i] * flakes.vx[i] + flakes.vy[i] * flakes.vy[i]
+ flakes.vz[i] * flakes.vz[i]);
float dx, dy, dz;
if (speed <= 1.0E-4f) {
dx = up.x;
dy = up.y;
dz = up.z;
} else {
dx = flakes.vx[i] / speed;
dy = flakes.vy[i] / speed;
dz = flakes.vz[i] / speed;
}
float sx = dy * vz - dz * vy, sy = dz * vx - dx * vz, sz = dx * vy - dy * vx;
float sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz);
if (sideLen <= 1.0E-4f) {
sx = up.y * vz - up.z * vy;
sy = up.z * vx - up.x * vz;
sz = up.x * vy - up.y * vx;
sideLen = (float) Math.sqrt(sx * sx + sy * sy + sz * sz);
if (sideLen <= 1.0E-4f) continue;
}
sx /= sideLen;
sy /= sideLen;
sz /= sideLen;
float radius = radius(sizeSetting, flakes.size[i], dist);
float back = streakLength(radius, speed, flakes.size[i]);
float front = frontLength(radius, back);
RenderColor center = new RenderColor(withAlpha(rgb, alpha));
RenderColor edge = new RenderColor(withAlpha(rgb, 0.0f));
emit(mesh, px, py, pz, sx * radius, sy * radius, sz * radius, dx * front, dy * front, dz * front,
center, edge);
emit(mesh, px, py, pz, -sx * radius, -sy * radius, -sz * radius, -dx * back, -dy * back, -dz * back,
center, edge);
}
}
/** Один змей: центр непрозрачный, сторона, острие и противоположная сторона прозрачные. */
private static void emit(MeshBuilder mesh, float cx, float cy, float cz, float sx, float sy, float sz,
float tx, float ty, float tz, RenderColor center, RenderColor edge) {
mesh.ensureQuadCapacity();
int a = mesh.vec3(cx, cy, cz).color(center).next();
int b = mesh.vec3(cx - sx, cy - sy, cz - sz).color(edge).next();
int c = mesh.vec3(cx + tx, cy + ty, cz + tz).color(edge).next();
int d = mesh.vec3(cx + sx, cy + sy, cz + sz).color(edge).next();
mesh.quad(a, b, c, d);
}
/** Снежинки позади камеры не рисуются, кроме совсем близких (не пропадают на краю экрана). */
static boolean isBehind(float dotWithForward, float dist) {
return dist > BEHIND_MIN_DISTANCE && dotWithForward < BEHIND_DOT;
}
/**
* Видимость снежинки: невидима вплотную к камере, плавно проявляется с расстоянием и возрастом;
* масштабируется прозрачностью и долей силы шторма от интенсивности.
*/
static float flakeAlpha(float dist, float ageSeconds, float baseAlpha, float opacity, float strength,
float intensity) {
float near = clamp01((dist - NEAR_FADE_START) / NEAR_FADE_SPAN);
float born = Math.min(ageSeconds / FADE_IN_SECONDS, 1.0f);
float share = clamp01(strength / Math.max(intensity, 0.01f));
return opacity * share * baseAlpha * near * Math.max(0.0f, born);
}
/** Радиус снежинки: вдали не меньше угловых 0.0024, чтобы не пропадала. */
static float radius(float sizeSetting, float flakeSize, float dist) {
return Math.max(FLAKE_RADIUS * sizeSetting * flakeSize, dist * MIN_ANGULAR_RADIUS);
}
/** Длина хвоста за снежинкой: растёт со скоростью. */
static float streakLength(float radius, float speed, float flakeSize) {
return radius + speed * STREAK_TIME * flakeSize;
}
/** Длина носа перед снежинкой: короче хвоста, у медленной равна радиусу. */
static float frontLength(float radius, float back) {
return back * STREAK_FRONT_SHARE + radius * (1.0f - STREAK_FRONT_SHARE);
}
static int withAlpha(int rgb, float alpha) {
int a = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
return (a << 24) | (rgb & 0x00FFFFFF);
}
private static float clamp01(float v) {
return v < 0.0f ? 0.0f : Math.min(v, 1.0f);
}
}

View file

@ -0,0 +1,206 @@
package dev.loki.lovisual.features.module.modules.visuals.weather.blizzard;
import java.util.Random;
/**
* Чистая физика снежинок по образцу evo: спавн в цилиндре вокруг камеры между землёй (карта высот) и потолком
* над головой, ветер с порывами, турбулентность, рецикл при выходе из зоны или касании земли.
* Под крышей земля выше потолка спавна, поэтому снега внутри помещений нет. Без зависимостей от Minecraft.
*/
final class SnowFlakes {
static final int MAX_FLAKES = 12000;
static final float FALL_SPEED = 1.6f;
static final float AREA_FACTOR = 9.0f;
static final double SPAWN_CEILING = 8.0;
static final double SPAWN_FLOOR_MARGIN = 6.0;
static final double RECYCLE_MARGIN = 1.25;
static final double RECYCLE_HEIGHT = 16.0;
static final int SPAWN_ATTEMPTS = 3;
static final float WIND_SPEED_MAX = 16.0f;
static final float TURBULENCE_AMPLITUDE = 1.1f;
static final float RISE_PER_SECOND = 0.45f;
static final float FALL_PER_SECOND = 0.6f;
static final float EXPOSURE_PER_SECOND = 1.4f;
/** Высота верхнего блока, который останавливает снег, в колонке (x, z) (карта MOTION_BLOCKING). */
@FunctionalInterface
interface Ground {
float height(int x, int z);
}
/** Ветер: горизонтальная скорость и лёгкий вертикальный снос. */
record Wind(double x, double y, double z) {
static final Wind ZERO = new Wind(0.0, 0.0, 0.0);
}
final double[] x = new double[MAX_FLAKES];
final double[] y = new double[MAX_FLAKES];
final double[] z = new double[MAX_FLAKES];
final float[] vx = new float[MAX_FLAKES];
final float[] vy = new float[MAX_FLAKES];
final float[] vz = new float[MAX_FLAKES];
final float[] fall = new float[MAX_FLAKES];
final float[] size = new float[MAX_FLAKES];
final float[] alpha = new float[MAX_FLAKES];
final float[] phase = new float[MAX_FLAKES];
final float[] rate = new float[MAX_FLAKES];
final float[] age = new float[MAX_FLAKES];
private final float[] ground = new float[MAX_FLAKES];
private final int[] columnX = new int[MAX_FLAKES];
private final int[] columnZ = new int[MAX_FLAKES];
int count;
private final Random random = new Random(1L);
void clear() {
count = 0;
}
/** Фиксация seed: детерминизм для тестов. */
void reseed(long seed) {
random.setSeed(seed);
}
/** Шаг к цели с постоянной скоростью, без перелёта. */
static float approach(float current, float target, float perSecond, float dt) {
float step = perSecond * dt;
if (Math.abs(target - current) <= step) return target;
return current + (target > current ? step : -step);
}
/** Сила шторма: разгон 0.45/с, затухание 0.6/с. */
static float ramp(float current, float target, float dt) {
return approach(current, target, target > current ? RISE_PER_SECOND : FALL_PER_SECOND, dt);
}
/** Цель экспозиции: 1 на открытом небе (камера не ниже верхнего блока колонки), 0 под крышей. */
static float exposureTarget(double camY, float groundHeight) {
return camY < groundHeight ? 0.0f : 1.0f;
}
/**
* Целевая сила шторма. Без «только в снегопад» это просто интенсивность; иначе шторм идёт, пока в биоме
* игрока снег и идёт осадок, и растёт с его силой.
*/
static float targetStrength(float intensity, boolean onlyInSnowfall, float rainLevel, boolean snowBiome) {
if (!onlyInSnowfall) return intensity;
if (rainLevel <= 0.02f || !snowBiome) return 0.0f;
return Math.max(0.0f, Math.min(1.0f, rainLevel)) * intensity;
}
/** Целевое число частиц: площадь цилиндра * плотность * сила шторма, не больше {@link #MAX_FLAKES}. */
static int targetCount(float radius, float density, float strength) {
if (radius <= 0f || density <= 0f || strength <= 0f) return 0;
double raw = Math.PI * radius * radius * AREA_FACTOR * density * strength;
return (int) Math.min(MAX_FLAKES, raw);
}
/** Ветер с порывами: направление медленно поворачивает, скорость дышит двумя синусами. */
static Wind wind(double seconds, float windPower, float strength) {
if (strength <= 0f || windPower <= 0f) return Wind.ZERO;
double angle = seconds * 0.02 + Math.sin(seconds * 0.9 * 0.5) * 0.35;
double gust = 1.0 + Math.sin(seconds * 0.9) * 0.35 + Math.sin(seconds * 2.3) * 0.15;
double speed = windPower * WIND_SPEED_MAX * gust * strength;
return new Wind(Math.cos(angle) * speed, Math.sin(seconds * 2.3) * speed * 0.12, Math.sin(angle) * speed);
}
/** Амплитуда боковой турбулентности (блоков/с) по настройке 0..1. */
static float turbulence(float setting) {
return Math.max(0f, setting) * TURBULENCE_AMPLITUDE;
}
void update(float dt, double camX, double camY, double camZ, float radius, Wind wind, float turbulence,
int target, double seconds, Ground ground) {
if (dt <= 0f || target < 0) return;
target = Math.min(target, MAX_FLAKES);
if (count > target) count = target;
while (count < target && respawn(count, camX, camY, camZ, radius, wind, ground)) count++;
double recycle = radius * RECYCLE_MARGIN;
double recycleSq = recycle * recycle;
for (int i = count - 1; i >= 0; i--) {
float wobbleX = (float) Math.sin(seconds * rate[i] + phase[i]) * turbulence;
float wobbleZ = (float) Math.cos(seconds * rate[i] * 0.83f + phase[i] * 1.7f) * turbulence;
float nvx = (float) wind.x() + wobbleX;
float nvy = (float) wind.y() - FALL_SPEED * fall[i];
float nvz = (float) wind.z() + wobbleZ;
double nx = x[i] + nvx * dt;
double ny = y[i] + nvy * dt;
double nz = z[i] + nvz * dt;
int cx = (int) Math.floor(nx);
int cz = (int) Math.floor(nz);
if (cx != columnX[i] || cz != columnZ[i]) {
columnX[i] = cx;
columnZ[i] = cz;
this.ground[i] = ground.height(cx, cz);
}
double dx = nx - camX;
double dz = nz - camZ;
boolean lost = ny <= this.ground[i] || dx * dx + dz * dz > recycleSq || ny > camY + RECYCLE_HEIGHT;
if (lost) {
if (!respawn(i, camX, camY, camZ, radius, wind, ground)) remove(i);
continue;
}
x[i] = nx;
y[i] = ny;
z[i] = nz;
vx[i] = nvx;
vy[i] = nvy;
vz[i] = nvz;
age[i] += dt;
}
}
private void remove(int i) {
int last = --count;
if (i == last) return;
x[i] = x[last];
y[i] = y[last];
z[i] = z[last];
vx[i] = vx[last];
vy[i] = vy[last];
vz[i] = vz[last];
fall[i] = fall[last];
size[i] = size[last];
alpha[i] = alpha[last];
phase[i] = phase[last];
rate[i] = rate[last];
age[i] = age[last];
ground[i] = ground[last];
columnX[i] = columnX[last];
columnZ[i] = columnZ[last];
}
/** Ставит снежинку в случайное место цилиндра между землёй и потолком; false, если над колонкой крыша. */
private boolean respawn(int i, double camX, double camY, double camZ, float radius, Wind wind, Ground world) {
for (int attempt = 0; attempt < SPAWN_ATTEMPTS; attempt++) {
double angle = random.nextDouble() * Math.PI * 2.0;
double dist = radius * Math.sqrt(random.nextDouble());
double px = camX + Math.cos(angle) * dist;
double pz = camZ + Math.sin(angle) * dist;
int cx = (int) Math.floor(px);
int cz = (int) Math.floor(pz);
float height = world.height(cx, cz);
double ceiling = camY + SPAWN_CEILING;
double floor = Math.max(height, camY - SPAWN_FLOOR_MARGIN);
if (ceiling <= floor) continue;
x[i] = px;
y[i] = floor + random.nextDouble() * (ceiling - floor);
z[i] = pz;
fall[i] = 0.6f + random.nextFloat() * 0.9f;
size[i] = 0.55f + random.nextFloat() * 0.9f;
alpha[i] = 0.45f + random.nextFloat() * 0.55f;
phase[i] = random.nextFloat() * (float) (Math.PI * 2.0);
rate[i] = 1.2f + random.nextFloat() * 2.2f;
age[i] = 0f;
vx[i] = (float) wind.x();
vy[i] = -FALL_SPEED;
vz[i] = (float) wind.z();
ground[i] = height;
columnX[i] = cx;
columnZ[i] = cz;
return true;
}
return false;
}
}

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