Compare commits
10 commits
44353e893c
...
6c641c4a94
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c641c4a94 | |||
| 60b4fedb29 | |||
| 9661ff638d | |||
| 622984f619 | |||
| a4638d386d | |||
| e82efb74e7 | |||
| 8d9a2ad0d6 | |||
| 483d6bfb93 | |||
| e604a48c31 | |||
| 0504be5b57 |
187 changed files with 9980 additions and 1180 deletions
|
|
@ -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
17
backend/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
backend/chat-service/Cargo.toml
Normal file
23
backend/chat-service/Cargo.toml
Normal 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"
|
||||
20
backend/chat-service/Dockerfile
Normal file
20
backend/chat-service/Dockerfile
Normal 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"]
|
||||
47
backend/chat-service/PLAN.md
Normal file
47
backend/chat-service/PLAN.md
Normal 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), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).
|
||||
|
|
@ -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.
|
||||
45
backend/chat-service/src/config.rs
Normal file
45
backend/chat-service/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
27
backend/chat-service/src/lib.rs
Normal file
27
backend/chat-service/src/lib.rs
Normal 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)
|
||||
}
|
||||
13
backend/chat-service/src/main.rs
Normal file
13
backend/chat-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
77
backend/chat-service/src/presence/handlers.rs
Normal file
77
backend/chat-service/src/presence/handlers.rs
Normal 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()
|
||||
}
|
||||
20
backend/chat-service/src/presence/mod.rs
Normal file
20
backend/chat-service/src/presence/mod.rs
Normal 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
46
backend/chat-service/src/presence/payload.rs
Normal file
46
backend/chat-service/src/presence/payload.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
188
backend/chat-service/src/presence/store.rs
Normal file
188
backend/chat-service/src/presence/store.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
71
backend/chat-service/tests/presence.rs
Normal file
71
backend/chat-service/tests/presence.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
875
mod/TODO.md
875
mod/TODO.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
770
mod/docs/TODO_HISTORY.md
Normal 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` падает при нарушении),
|
||||
без длинного тире в текстах сайта и ответах.
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(" ")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue