chore(history): squash 6 commit(s) from 2026-09-16

- feat(balancer): новый модуль Balancer + команда %tier — тиры с mctiers.com
- docs(todo): отмечен Balancer как выполненный
- feat(trinket): новый модуль Trinket — прыгающий шарик-компаньон у ног игрока
- docs(todo): отмечен Trinket как выполненный
- feat(chams): добавлен режим Invert руки (HandsShader) — закрывает Фазу 7
- docs(todo): Фаза 7 полностью закрыта
This commit is contained in:
loki5512344 2026-09-16 10:11:16 +02:00
parent 4a73a70dbf
commit 25060c9fa9
14 changed files with 781 additions and 17 deletions

43
TODO.md
View file

@ -28,8 +28,9 @@
- [x] Фаза 4: Security (BackdoorProtection, StaffTracker)
- [x] Фаза 5: Нормализация модулей (ленивый Minecraft, GameClock)
- [x] Фаза 6: Тесты (EventBus, конфиг, утилиты) — см. `combatant-client-26.2/TODO.md`
- [ ] Фаза 7: Новые модули по мотивам SoupVisuals — Cosmetics Overhaul ЗАВЕРШЁН 2026-09-11,
см. ниже (остатки: Gps, HitBubbles, AmbientParticles, EndCrystal, Trinket, Balancer, HandsShader)
- [x] Фаза 7: Новые модули по мотивам SoupVisuals — ЗАВЕРШЕНА 2026-09-16 (Cosmetics
Overhaul 2026-09-11; Gps/HitBubbles/AmbientParticles/EndCrystal/Balancer/Trinket/
HandsShader — 2026-09-15…16, см. ниже)
- [ ] Фаза 8.5: Аудит структуры ≤200/≤4 — 8.5.1 (папки >4) ЗАВЕРШЕНА 2026-09-10
(0 папок >4, коммиты 8846d53…303b17e); далее 8.5.2 (гиганты >600),
план в `combatant-client-26.2/TODO.md`
@ -76,10 +77,15 @@
ваниль убирает сущность мгновенно). Референс: `other/EndCrystal.java` — идея взята
(кастомный цвет, "доиграть" разрушение), Sync/Custom×4-цвета+Wave/Vertex упрощены до
общего AnimatedRenderColors. Коммит 50db8e3.
- [ ] **Trinket 2.0** — апгрейд нашего CosmeticDisplay до уровня Soup Trinket:
иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл
(цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы
(у Soup 315 стр.). Референс: `other/Trinket.java`
- [x] **Trinket** (не "2.0" — CosmeticDisplay удалён откатом 2026-09-11, апгрейдить нечего) —
ЗАВЕРШЕНО 2026-09-16: отдельный модуль `visuals/items/Trinket.java` в стиле текущих
ChinaHat/TazikHat. `util/physics/TrinketPhysics` (чистая, тесты) — шарик на пружине
у ног игрока (гравитация/отскок/тетер). Рендер — тонированный billboard (наш
AnimatedRenderColors) + затухающий шлейф (кольцевой буфер) + звук на отскок
(ванильный SoundEvents.SLIME_JUMP). Библиотека 10 иконок-спутников референса и
8 кастомных звуковых тем НЕ портированы — нужны новые ассеты. Референс:
`other/Trinket.java` (там тоже почти нет реализации — только настройки).
Коммит 840a716.
- [x] **Cosmetics Overhaul (Unified Cosmetics)** — ЗАВЕРШЁН 2026-09-11:
слоты `CosmeticSlot` (MODEL/PET/HEAD/SHOULDERS/BACK/WAIST/LEGS/HELD), парсинг
`slot` из avatar.json + эвристика по имени папки, `CosmeticManager` на EnumMap
@ -95,14 +101,23 @@
- Не сделано из плана (следующий заход): иконки-превью built-in в списке слота,
правка слотов SHOULDERS/LEGS/PET из экрана (сейчас только HEAD/BACK built-in),
предпросмотр до эквипа.
- [ ] **Balancer** (mctiers) — тиры игроков с mctiers.info (у Soup отдельная категория
MCTiers). Референс: `mctiers/Balancer.java`
- [ ] **HandsShader: новые шейдеры руки** — у Soup 10 режимов (Solid, Chroma, Balatro,
Smoke, Stripes, Glow, Glass, Invert, Snow) + dual-режим с blend-режимами
(Mix/Add/Multiply/Screen/Overlay/Difference); шейдеры лежат в
`ref/soupvisuals-src/assets/soupapi/shaders/hand/`. Пере-реализовать недостающие
и добавить к нашим (BackSword/Chams hand shaders уже есть dual — сверить набор).
Референс: `visuals/HandsShader.java`
- [x] **Balancer** (mctiers) — ЗАВЕРШЕНО 2026-09-16: `features/module/modules/misc/Balancer.java`
(fixType Vanilla/UHC/Pot/NethOP/SMP/Sword/Axe/Mace) + `util/mctiers/MCTiersCache`
(асинхронный кэш, паттерн SkinManager) + `util/mctiers/MCTiersTier` (форматирование,
тесты) + команда `%tier <ник> [режим]`. Референс `mctiers/Balancer.java` почти пуст —
логика была в недоступной McTiersModule-базе; реальный публичный API mctiers.com
найден и проверен через сторонний open-source плагин (sinnayuh/MCTier):
`GET https://mctiers.com/api/search_profile/<username>`. Коммит cf81ee4.
- [x] **HandsShader** — ЗАВЕРШЕНО 2026-09-16: сверка показала, что Chams уже реализует
9/10 режимов Soup (Solid/Chroma/Balatro/Smoke/Stripes/Glow/Glass/Snow + свои
Metallic/Noise) и полный dual/blend-набор (Mix/Add/Multiply/Screen/Overlay/
Difference, `HandBlendUniforms`) — сверх ожиданий. Не хватало только "Invert":
добавлен `hand_chams_invert.frag` (RGB-инверт под маской руки через уже
существующий UBO HandChams/u_Mix.x, без HSV-тумблеров и edge-glow референса —
это дублировало бы наш Glow) + регистрация пайплайна + вписан в оба mode-списка
Chams (основной и dual shader1/shader2). Референс: `visuals/HandsShader.java`.
Коммит 76ffdec. **Это был последний пункт Фазы 7** — SoupVisuals-раздел закрыт
полностью (Gps/HitBubbles/AmbientParticles/EndCrystal/Balancer/Trinket/HandsShader).
- [x] **Фикс ChinaHat** — текущая геометрия кривая (смахивает на самбреро/зонт);
переделать модель в нормальную конусную шляпу по референсу Soup
(`visuals/ChinaHat.java` + `ChinaHatModel.java`).

View file

@ -0,0 +1,73 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.command.impl.world;
import dev.loki.lovisual.features.command.core.CommandContext;
import dev.loki.lovisual.features.command.core.CommandInfo;
import dev.loki.lovisual.features.command.util.ClientCommand;
import dev.loki.lovisual.features.command.util.CommandOutput;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.misc.Balancer;
import dev.loki.lovisual.util.mctiers.MCTiersCache;
import dev.loki.lovisual.util.mctiers.MCTiersTier;
import java.util.Locale;
/**
* {@code %tier <ник> [режим]} — тир игрока с mctiers.com. Первый запрос уходит
* в фон (см. {@link MCTiersCache}) и почти всегда возвращает null сразу же —
* повторный вызов команды через пару секунд покажет результат из кэша.
*/
@CommandInfo(
id = "tier",
aliases = {"mctier"},
usage = "%tier <player> [vanilla|uhc|pot|nethop|smp|sword|axe|mace]",
descriptionKey = "command.tier.description"
)
public final class TierCommand implements ClientCommand {
@Override
public boolean execute(CommandContext ctx) {
String player = ctx.arg(0);
if (player == null || player.isBlank()) {
CommandOutput.warning("Usage: " + metadata().usage());
return true;
}
Balancer balancer = Modules.get(Balancer.class);
String fixType = balancer != null ? balancer.fixType() : "Vanilla";
String override = ctx.arg(1);
if (override != null && !override.isBlank()) {
fixType = normalizeFixType(override);
}
String apiMode = MCTiersTier.modeApiKey(fixType);
MCTiersTier tier = MCTiersCache.getTier(player, apiMode);
if (tier == null) {
CommandOutput.send("Fetching mctiers.com data for " + player + "... run %tier again in a moment.");
return true;
}
CommandOutput.success(String.format(Locale.ROOT, "%s — %s: %s%s",
player, fixType, tier.label(), tier.retired() ? " (retired)" : ""));
return true;
}
private static String normalizeFixType(String raw) {
return switch (raw.toLowerCase(Locale.ROOT)) {
case "uhc" -> "UHC";
case "pot" -> "Pot";
case "nethop", "neth_pot", "netherite" -> "NethOP";
case "smp" -> "SMP";
case "sword" -> "Sword";
case "axe" -> "Axe";
case "mace" -> "Mace";
default -> "Vanilla";
};
}
}

View file

@ -0,0 +1,44 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.misc;
import dev.loki.lovisual.config.values.mode.ModeValue;
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.mctiers.MCTiersCache;
import dev.loki.lovisual.util.mctiers.MCTiersTier;
/**
* Тиры игроков с mctiers.com (референс — Soup Balancer): выбор игрового
* режима, тир по которому смотрим. Референсный Balancer сам почти не содержит
* логики — она была в недоступной нам {@code McTiersModule}-базе; сеть/кэш/
* парсинг написаны заново в {@link MCTiersCache}. Смотреть тир игрока:
* {@code %tier <ник>} (см. {@code TierCommand}) или {@link #tierFor(String)}
* для точечной интеграции в другие модули (например NameTags).
*/
@ModuleInfo(
id = "balancer",
displayName = "Balancer",
category = ModuleCategory.MISC,
description = "Тиры игроков с mctiers.com по выбранному игровому режиму"
)
public final class Balancer extends Module {
private final ModeValue fixType = modeSetting("fixType", "fix_type", "Vanilla",
"Vanilla", "UHC", "Pot", "NethOP", "SMP", "Sword", "Axe", "Mace");
public String fixType() {
return fixType.get();
}
/** @return тир игрока по текущему выбранному режиму, либо null (нет данных ещё/вообще). */
public MCTiersTier tierFor(String playerName) {
return MCTiersCache.getTier(playerName, MCTiersTier.modeApiKey(fixType.get()));
}
}

View file

@ -0,0 +1,187 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.items;
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.render.engine.RenderState;
import dev.loki.lovisual.render.engine.animation.AnimatedColorModeOptions;
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
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.texture.TextureStorage;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.util.physics.TrinketPhysics;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Компаньон-шарик, подвешенный у ног игрока: падает под гравитацией,
* отскакивает, тянется обратно пружиной (см. {@link TrinketPhysics} — чистая
* симуляция, покрыта тестами), оставляет затухающий шлейф и звенит при
* отскоке. Референс: Soup other/Trinket.java — модуль-настройки без
* реализации (физика/рендер были в недоступной нам базе), физика и рендер
* написаны с нуля под наш движок. Библиотека из 10 иконок-спутников
* (Atom/Ball/Cat/...) и полный набор из 8 звуковых тем референса не
* портированы — нужны новые ассеты; вместо этого светящийся шарик тонируется
* через уже существующий {@link AnimatedRenderColors}, а звук — из ванильных
* {@link SoundEvents} (без новых .ogg).
*/
@ModuleInfo(
id = "trinket",
displayName = "Trinket",
category = ModuleCategory.VISUALS,
description = "Компаньон-шарик у ног игрока: физика прыжков, шлейф, звук отскока"
)
public final class Trinket extends Module {
private static final int TRAIL_CAPACITY = 24;
private static final double TICK_DT = 0.05;
private final NumberValue<Float> gravity = num("gravity", 20.0f, 5.0f, 40.0f);
private final NumberValue<Float> weight = num("weight", 1.0f, 0.2f, 2.0f);
private final NumberValue<Float> friction = num("friction", 0.98f, 0.8f, 1.0f);
private final NumberValue<Float> bounce = num("bounce", 0.7f, 0.0f, 1.0f);
private final NumberValue<Float> groundFriction = num("ground_friction", 0.9f, 0.7f, 1.0f);
private final NumberValue<Float> tetherRadius = num("tether_radius", 1.2f, 0.3f, 3.0f);
private final NumberValue<Float> iconSize = num("icon_size", 0.28f, 0.1f, 0.6f);
private final ModeValue colorMode = modeSetting("colorMode", "color_mode", "Static", AnimatedColorModeOptions.OPTIONS);
private final NumberValue<Integer> colorSpeed = num("color_speed", 18, 2, 54);
private final RGBAColorValue colorValue = color("color", "#FF7FE0FF");
private final RGBAColorValue colorValue2 = visibleWhen(color("color2", "#FFFF9F40"), this::usesSecondaryColor);
private final BooleanValue trailEnabled = bool("trail_enabled", true);
private final NumberValue<Integer> trailLength = visibleWhen(num("trail_length", 15, 5, TRAIL_CAPACITY), trailEnabled::get);
private final BooleanValue soundEnabled = bool("sound_enabled", true);
private final NumberValue<Float> soundVolume = visibleWhen(num("sound_volume", 0.5f, 0.0f, 1.5f), soundEnabled::get);
private final TrinketPhysics physics = new TrinketPhysics();
private final double[] trailX = new double[TRAIL_CAPACITY];
private final double[] trailY = new double[TRAIL_CAPACITY];
private final double[] trailZ = new double[TRAIL_CAPACITY];
private int trailHead;
private int trailCount;
@Override
public void onEnable() {
physics.reset();
trailHead = 0;
trailCount = 0;
}
private boolean usesSecondaryColor() {
return AnimatedRenderColors.usesSecondary(AnimatedColorModeOptions.parse(colorMode.get()));
}
private int resolveColorArgb() {
return AnimatedRenderColors.resolve(
AnimatedColorModeOptions.parse(colorMode.get()), colorSpeed.get(), 0,
colorValue.getArgb(), colorValue2.getArgb());
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (!isEnabled() || player == null || mc.level == null) return;
boolean bounced = physics.tick(TICK_DT, gravity.get(), weight.get(), friction.get(),
bounce.get(), groundFriction.get(), tetherRadius.get());
if (trailEnabled.get()) {
trailX[trailHead] = physics.x();
trailY[trailHead] = physics.y();
trailZ[trailHead] = physics.z();
trailHead = (trailHead + 1) % trailX.length;
if (trailCount < trailX.length) trailCount++;
}
if (bounced && soundEnabled.get() && soundVolume.get() > 0.001f) {
Vec3 pos = player.position();
mc.level.playSound(player, pos.x, pos.y, pos.z,
SoundEvents.SLIME_JUMP, SoundSource.PLAYERS, soundVolume.get(), 1.4f);
}
}
@Override
public void onDisable() {
physics.reset();
trailCount = 0;
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.player == null) return;
Vec3 anchor = mc.player.getPosition(tickDelta);
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE, TextureStorage.FIRE_FLY, Renderer3D.DepthMode.MAIN);
if (mesh == null) return;
Quaternionf camRot = RenderState.cameraRotation;
int color = resolveColorArgb();
if (trailEnabled.get() && trailCount > 1) {
int visible = Math.min(trailCount, Math.max(2, trailLength.get()));
for (int i = 0; i < visible; i++) {
int idx = Math.floorMod(trailHead - 1 - i, trailX.length);
float fade = 1.0f - (i / (float) visible);
if (fade <= 0.02f) continue;
int argb = applyOpacity(color, fade * 0.6f);
double x = anchor.x + trailX[idx];
double y = anchor.y + trailY[idx];
double z = anchor.z + trailZ[idx];
addBillboard(mesh, x, y, z, iconSize.get() * (0.4f + 0.4f * fade), camRot, argb);
}
}
double x = anchor.x + physics.x();
double y = anchor.y + physics.y();
double z = anchor.z + physics.z();
addBillboard(mesh, x, y, z, iconSize.get(), camRot, applyOpacity(color, 1.0f));
}
private static void addBillboard(MeshBuilder mesh, double cx, double cy, double cz,
float size, Quaternionf camRot, int argb) {
Vector3f right = new Vector3f(1, 0, 0).rotate(camRot).mul(size);
Vector3f up = new Vector3f(0, 1, 0).rotate(camRot).mul(size);
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(cx - right.x() - up.x(), cy - right.y() - up.y(), cz - right.z() - up.z()).vec2(0, 1).color(new RenderColor(argb)).next();
int i2 = mesh.vec3(cx + right.x() - up.x(), cy + right.y() - up.y(), cz + right.z() - up.z()).vec2(1, 1).color(new RenderColor(argb)).next();
int i3 = mesh.vec3(cx + right.x() + up.x(), cy + right.y() + up.y(), cz + right.z() + up.z()).vec2(1, 0).color(new RenderColor(argb)).next();
int i4 = mesh.vec3(cx - right.x() + up.x(), cy - right.y() + up.y(), cz - right.z() + up.z()).vec2(0, 0).color(new RenderColor(argb)).next();
mesh.quad(i1, i2, i3, i4);
}
private static int applyOpacity(int argb, float opacity) {
int a = (argb >>> 24) & 0xFF;
int na = Mth.clamp((int) (a * Mth.clamp(opacity, 0f, 1f)), 0, 255);
return (na << 24) | (argb & 0x00FFFFFF);
}
}

View file

@ -129,7 +129,7 @@ public class Chams extends Module {
private static final float NOISE_BREATHE = 0.55f;
private static final float NOISE_DENSITY = 1.0f;
private final ModeValue mode =
modeSetting("handChamsMode", SETTING_MODE, "Smoke", "Smoke", "Metallic", "Glass", "Noise", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro");
modeSetting("handChamsMode", SETTING_MODE, "Smoke", "Smoke", "Metallic", "Glass", "Noise", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro", "Invert");
private final BooleanValue hands =
bool("chamsHands", SETTING_HANDS, true);
@ -198,9 +198,9 @@ public class Chams extends Module {
private final BooleanValue dualShader =
bool("chamsDualShader", SETTING_DUAL_SHADER, false);
private final ModeValue shader1 =
visibleWhen(modeSetting("chamsShader1", SETTING_SHADER_1, "Glow", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro"), dualShader::get);
visibleWhen(modeSetting("chamsShader1", SETTING_SHADER_1, "Glow", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro", "Invert"), dualShader::get);
private final ModeValue shader2 =
visibleWhen(modeSetting("chamsShader2", SETTING_SHADER_2, "Chroma", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro"), dualShader::get);
visibleWhen(modeSetting("chamsShader2", SETTING_SHADER_2, "Chroma", "Snow", "Chroma", "Solid", "Stripes", "Glow", "Balatro", "Invert"), dualShader::get);
private final ModeValue blendMode =
visibleWhen(modeSetting("chamsBlendMode", SETTING_BLEND_MODE, "Add", "Mix", "Add", "Multiply", "Screen", "Overlay", "Difference"), dualShader::get);
private final NumberValue<Float> blendIntensity =
@ -845,6 +845,9 @@ public class Chams extends Module {
glowPowerV = glowShaderPower.get();
glowDisp = glowShaderDispersion.get();
}
case "Invert" -> {
// шейдер сам ничего не читает из colorA..D — эффект чисто по u_Mix.x/u_Flags.x
}
default -> {
colorA = balatroColor1.getArgb();
colorB = balatroColor2.getArgb();
@ -863,6 +866,7 @@ public class Chams extends Module {
case "Solid" -> LoVisualRenderPipelines.HAND_CHAMS_SOLID;
case "Stripes" -> LoVisualRenderPipelines.HAND_CHAMS_STRIPES;
case "Glow" -> LoVisualRenderPipelines.HAND_CHAMS_GLOW;
case "Invert" -> LoVisualRenderPipelines.HAND_CHAMS_INVERT;
default -> LoVisualRenderPipelines.HAND_CHAMS_BALATRO;
};

View file

@ -134,6 +134,7 @@ public enum LoVisualRenderPipelines {
public static final Identifier SHADER_HAND_CHAMS_STRIPES_FRAG = shader("hand_chams_stripes.frag");
public static final Identifier SHADER_HAND_CHAMS_GLOW_FRAG = shader("hand_chams_glow.frag");
public static final Identifier SHADER_HAND_CHAMS_SNOW_FRAG = shader("hand_chams_snow.frag");
public static final Identifier SHADER_HAND_CHAMS_INVERT_FRAG = shader("hand_chams_invert.frag");
public static final Identifier SHADER_HAND_CHAMS_BALATRO_FRAG = shader("hand_chams_balatro.frag");
public static final Identifier SHADER_HAND_CHAMS_BLEND_FRAG = shader("hand_chams_blend.frag");
// Sky & menu backgrounds
@ -303,6 +304,7 @@ public enum LoVisualRenderPipelines {
public static final RenderPipeline HAND_CHAMS_STRIPES = add(ScreenPipelines.handEffect("hand_chams_stripes", SHADER_HAND_CHAMS_STRIPES_FRAG, "HandChams"));
public static final RenderPipeline HAND_CHAMS_GLOW = add(ScreenPipelines.handEffect("hand_chams_glow", SHADER_HAND_CHAMS_GLOW_FRAG, "HandChams"));
public static final RenderPipeline HAND_CHAMS_SNOW = add(ScreenPipelines.handEffect("hand_chams_snow", SHADER_HAND_CHAMS_SNOW_FRAG, "HandChams"));
public static final RenderPipeline HAND_CHAMS_INVERT = add(ScreenPipelines.handEffect("hand_chams_invert", SHADER_HAND_CHAMS_INVERT_FRAG, "HandChams"));
public static final RenderPipeline HAND_CHAMS_BALATRO = add(ScreenPipelines.handEffect("hand_chams_balatro", SHADER_HAND_CHAMS_BALATRO_FRAG, "HandChams"));
public static final RenderPipeline HAND_CHAMS_BLEND = add(ScreenPipelines.handBlendEffect("hand_chams_blend", SHADER_HAND_CHAMS_BLEND_FRAG));
/** No depth test; translucent; textured skybox with vanilla sky/fog blending. */

View file

@ -0,0 +1,124 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Асинхронный кэш профилей mctiers.com (публичный REST API, без ключа):
* {@code GET https://mctiers.com/api/search_profile/<username>} возвращает
* {@code rankings.<mode>.{tier,retired}} на игровой режим (vanilla/sword/
* uhc/pot/neth_pot/smp/axe/mace). Паттерн кэша и HTTP-вызова — как у
* {@link dev.loki.lovisual.features.account.SkinManager} (та же ниша:
* не блокировать рендер, тихо ронять сеть).
*/
public enum MCTiersCache {
;
private static final String API_URL = "https://mctiers.com/api/search_profile/%s";
private static final long TTL_MS = 5 * 60_000L;
private static final Map<String, CachedProfile> CACHE = new ConcurrentHashMap<>();
private static final Map<String, Boolean> LOADING = new ConcurrentHashMap<>();
private static final Executor EXECUTOR = Executors.newFixedThreadPool(2, runnable -> {
Thread thread = new Thread(runnable, "LoVisual-MCTiersLookup");
thread.setDaemon(true);
return thread;
});
/** @return тир для режима, либо null если ещё не загружен/нет ранга — запрос уходит в фон сам. */
public static MCTiersTier getTier(String playerName, String apiMode) {
String key = normalize(playerName);
if (key.isEmpty()) return null;
CachedProfile cached = CACHE.get(key);
if (cached != null && System.currentTimeMillis() - cached.fetchedAtMs < TTL_MS) {
return cached.rankings.get(apiMode);
}
if (LOADING.putIfAbsent(key, Boolean.TRUE) == null) {
CompletableFuture.runAsync(() -> load(playerName, key), EXECUTOR);
}
return cached != null ? cached.rankings.get(apiMode) : null;
}
public static void clearCache() {
CACHE.clear();
LOADING.clear();
}
private static void load(String playerName, String key) {
try {
JsonObject profile = fetchProfile(playerName);
if (profile == null) return;
Map<String, MCTiersTier> rankings = new ConcurrentHashMap<>();
if (profile.has("rankings") && profile.get("rankings").isJsonObject()) {
JsonObject rankingsJson = profile.getAsJsonObject("rankings");
for (String mode : rankingsJson.keySet()) {
JsonObject ranking = rankingsJson.getAsJsonObject(mode);
if (ranking == null || !ranking.has("tier")) continue;
int tier = ranking.get("tier").getAsInt();
boolean retired = ranking.has("retired") && ranking.get("retired").getAsBoolean();
rankings.put(mode, new MCTiersTier(tier, retired));
}
}
CACHE.put(key, new CachedProfile(rankings, System.currentTimeMillis()));
} catch (Throwable ignored) {
} finally {
LOADING.remove(key);
}
}
private static JsonObject fetchProfile(String playerName) {
try {
HttpURLConnection connection = (HttpURLConnection) URI
.create(String.format(API_URL, playerName))
.toURL()
.openConnection();
connection.setRequestMethod("GET");
connection.setInstanceFollowRedirects(true);
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("User-Agent", "LoVisual");
if (connection.getResponseCode() != 200) {
connection.disconnect();
return null;
}
try (InputStreamReader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) {
return JsonParser.parseReader(reader).getAsJsonObject();
} finally {
connection.disconnect();
}
} catch (Throwable ignored) {
return null;
}
}
private static String normalize(String name) {
return name == null ? "" : name.trim().toLowerCase(Locale.ROOT);
}
private record CachedProfile(Map<String, MCTiersTier> rankings, long fetchedAtMs) {
}
}

View file

@ -0,0 +1,56 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
/**
* Чистое форматирование одной строки рейтинга mctiers.com (тир 1..5,
* retired-флаг) в текст/цвет — вынесено из парсинга JSON, чтобы тестить
* без сети. Соответствие тиров цветам/подписям взято из публичного формата
* mctiers.com (HT1/LT2/LT3/LT4, T5+ как запасной вариант).
*/
public record MCTiersTier(int tier, boolean retired) {
public String label() {
return switch (tier) {
case 1 -> "HT1";
case 2 -> "LT2";
case 3 -> "LT3";
case 4 -> "LT4";
default -> "T" + tier;
};
}
/** ARGB, непрозрачный. */
public int colorArgb() {
return switch (tier) {
case 1 -> 0xFFFF5555;
case 2 -> 0xFFFF55FF;
case 3 -> 0xFF5555FF;
case 4 -> 0xFF55FF55;
default -> 0xFFAAAAAA;
};
}
public static String modeApiKey(String fixType) {
if (fixType == null) return "vanilla";
return switch (fixType) {
case "UHC" -> "uhc";
case "Pot" -> "pot";
case "NethOP" -> "neth_pot";
case "SMP" -> "smp";
case "Sword" -> "sword";
case "Axe" -> "axe";
case "Mace" -> "mace";
default -> "vanilla";
};
}
public static String modeDisplayName(String fixType) {
return fixType == null ? "Vanilla" : fixType;
}
}

View file

@ -0,0 +1,81 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.physics;
/**
* Чистая физика подвесной "игрушки"-компаньона (идея Soup Trinket): шарик на
* невидимой пружине у ног игрока, падает под гравитацией, отскакивает от
* условной земли (offsetY == 0 — уровень ног владельца) и притягивается
* обратно, если укатился дальше {@code tetherRadius}. Все координаты — смещение
* от игрока, не мировые. Без Minecraft-типов, покрыто тестами.
*/
public final class TrinketPhysics {
private static final double SPRING_STRENGTH = 6.0;
private double x, y, z;
private double vx, vy, vz;
public void reset() {
x = y = z = 0.0;
vx = vy = vz = 0.0;
}
/**
* Один шаг симуляции.
*
* @return true, если в этом шаге произошёл отскок от земли (offsetY достиг 0 сверху)
*/
public boolean tick(double dt, double gravity, double weight, double friction,
double bounce, double groundFriction, double tetherRadius) {
vy -= gravity * weight * dt;
x += vx * dt;
y += vy * dt;
z += vz * dt;
boolean bounced = false;
if (y <= 0.0 && vy < 0.0) {
y = 0.0;
vy = -vy * bounce;
vx *= groundFriction;
vz *= groundFriction;
bounced = true;
}
double horizontalDist = Math.sqrt(x * x + z * z);
if (horizontalDist > tetherRadius && horizontalDist > 1.0e-6) {
double pull = (horizontalDist - tetherRadius) * SPRING_STRENGTH * dt;
double nx = x / horizontalDist;
double nz = z / horizontalDist;
vx -= nx * pull;
vz -= nz * pull;
}
vx *= friction;
vz *= friction;
return bounced;
}
public double x() {
return x;
}
public double y() {
return y;
}
public double z() {
return z;
}
public double speed() {
return Math.sqrt(vx * vx + vy * vy + vz * vz);
}
}

View file

@ -3231,6 +3231,7 @@
"command.coordinates.description": "Shows the current player coordinates.",
"command.enemy.description": "Manages the client enemy list.",
"command.gps.description": "Sets or clears the Gps compass target.",
"command.tier.description": "Looks up a player's mctiers.com tier.",
"command.friend.description": "Manages the client friend list.",
"command.help.description": "Shows every available client command with clickable pages. Prefix: % (legacy @ also works).",
"command.hide.description": "Hides or shows a module in ModuleList without disabling it.",

View file

@ -3026,6 +3026,7 @@
"command.coordinates.description": "Показывает текущие координаты игрока.",
"command.enemy.description": "Управляет списком врагов клиента.",
"command.gps.description": "Задаёт или сбрасывает цель GPS-компаса.",
"command.tier.description": "Показывает тир игрока с mctiers.com.",
"command.friend.description": "Управляет списком друзей клиента.",
"command.help.description": "Показывает все доступные команды клиента с кликабельными страницами. Префикс: % (старый @ тоже работает).",
"command.hide.description": "Скрывает или показывает модуль в ModuleList, не отключая его.",

View file

@ -0,0 +1,38 @@
#version 330 core
in vec2 v_TexCoord;
out vec4 fragColor;
uniform sampler2D u_Src;
uniform sampler2D u_Mask;
layout (std140) uniform HandChams {
vec4 u_View; // x = resX, y = resY, z = time
vec4 u_Mix; // x = effectAlpha
vec4 u_ColorA;
vec4 u_ColorB;
vec4 u_ColorC;
vec4 u_ColorD;
vec4 u_Common;
vec4 u_Glow;
vec4 u_Flags; // x = hideHand
};
float handMaskAt(vec2 uv) {
vec4 m = texture(u_Mask, uv);
return max(m.a, max(m.r, max(m.g, m.b)));
}
void main() {
vec4 base = texture(u_Src, v_TexCoord);
float mask = handMaskAt(v_TexCoord);
if (mask <= 0.02 || u_Flags.x > 0.5) {
fragColor = base;
return;
}
float maskGain = smoothstep(0.02, 0.3, mask);
vec3 inverted = vec3(1.0) - base.rgb;
vec3 finalColor = mix(base.rgb, inverted, u_Mix.x * maskGain);
fragColor = vec4(finalColor, 1.0);
}

View file

@ -0,0 +1,51 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.mctiers;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class MCTiersTierTest {
@Test
void labelsMatchKnownTiers() {
assertEquals("HT1", new MCTiersTier(1, false).label());
assertEquals("LT2", new MCTiersTier(2, false).label());
assertEquals("LT3", new MCTiersTier(3, false).label());
assertEquals("LT4", new MCTiersTier(4, false).label());
assertEquals("T5", new MCTiersTier(5, false).label());
}
@Test
void colorsAreDistinctPerTier() {
int t1 = new MCTiersTier(1, false).colorArgb();
int t2 = new MCTiersTier(2, false).colorArgb();
int t3 = new MCTiersTier(3, false).colorArgb();
int t4 = new MCTiersTier(4, false).colorArgb();
assertEquals(4, java.util.Set.of(t1, t2, t3, t4).size());
}
@Test
void modeApiKeyMapsFixTypeToApiMode() {
assertEquals("vanilla", MCTiersTier.modeApiKey("Vanilla"));
assertEquals("uhc", MCTiersTier.modeApiKey("UHC"));
assertEquals("pot", MCTiersTier.modeApiKey("Pot"));
assertEquals("neth_pot", MCTiersTier.modeApiKey("NethOP"));
assertEquals("smp", MCTiersTier.modeApiKey("SMP"));
assertEquals("sword", MCTiersTier.modeApiKey("Sword"));
assertEquals("axe", MCTiersTier.modeApiKey("Axe"));
assertEquals("mace", MCTiersTier.modeApiKey("Mace"));
}
@Test
void modeApiKeyDefaultsToVanillaForUnknownOrNull() {
assertEquals("vanilla", MCTiersTier.modeApiKey(null));
assertEquals("vanilla", MCTiersTier.modeApiKey("Nonsense"));
}
}

View file

@ -0,0 +1,87 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.physics;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class TrinketPhysicsTest {
@Test
void restIsZeroAfterReset() {
TrinketPhysics p = new TrinketPhysics();
p.tick(0.05, 20.0, 1.0, 0.98, 0.7, 0.9, 1.0);
p.reset();
assertEquals(0.0, p.x());
assertEquals(0.0, p.y());
assertEquals(0.0, p.z());
assertEquals(0.0, p.speed());
}
@Test
void restingBallBouncesImmediatelyUnderGravity() {
// y=0 — это уровень земли (у ног владельца), поэтому шарик из состояния покоя
// на первом же тике проваливается под гравитацией и тут же отскакивает обратно —
// это и есть наблюдаемый эффект гравитации в модели "ноль высоты = земля".
TrinketPhysics p = new TrinketPhysics();
assertTrue(p.tick(0.05, 20.0, 1.0, 1.0, 0.7, 0.9, 5.0), "гравитация должна вжать шарик в землю на первом тике");
assertEquals(0.0, p.y(), 1.0e-9);
assertTrue(p.speed() > 0.0, "после отскока есть исходящая от земли скорость");
}
@Test
void bouncesWhenHittingGroundFromAbove() {
TrinketPhysics p = new TrinketPhysics();
boolean bouncedAtLeastOnce = false;
for (int i = 0; i < 200; i++) {
if (p.tick(0.05, 20.0, 1.0, 0.999, 0.7, 0.9, 5.0)) {
bouncedAtLeastOnce = true;
break;
}
}
assertTrue(bouncedAtLeastOnce, "падение с гравитацией должно рано или поздно удариться о землю (y=0)");
assertEquals(0.0, p.y(), 1.0e-9, "после отскока шарик стоит ровно на земле");
}
@Test
void repeatedBouncesLoseEnergyOverTime() {
// Каждый отскок домножает вертикальную скорость на bounce=0.5 — пик высоты после
// каждого следующего отскока должен быть меньше предыдущего (затухающий мяч).
TrinketPhysics p = new TrinketPhysics();
double firstBounceSpeed = -1;
double secondBounceSpeed = -1;
int bounces = 0;
for (int i = 0; i < 1000 && bounces < 2; i++) {
if (p.tick(0.02, 20.0, 1.0, 1.0, 0.5, 1.0, 100.0)) {
bounces++;
if (bounces == 1) firstBounceSpeed = p.speed();
else secondBounceSpeed = p.speed();
}
}
assertTrue(firstBounceSpeed > 0 && secondBounceSpeed > 0, "должно было произойти минимум два отскока");
assertTrue(secondBounceSpeed < firstBounceSpeed,
"второй отскок должен быть слабее первого: " + secondBounceSpeed + " >= " + firstBounceSpeed);
}
@Test
void tetherKeepsBallWithinBoundedDistance() {
// Прямой доступ к полям недоступен (инкапсуляция) — проверяем инвариант на реальной
// траектории: с гравитацией/отскоками и малым tetherRadius шарик не должен улетать
// в бесконечность, пружина стабилизирует расстояние от центра.
TrinketPhysics p = new TrinketPhysics();
double maxDist = 0;
for (int i = 0; i < 2000; i++) {
p.tick(0.05, 20.0, 1.0, 0.98, 0.6, 0.9, 1.5);
double dist = Math.sqrt(p.x() * p.x() + p.z() * p.z());
maxDist = Math.max(maxDist, dist);
}
assertTrue(maxDist < 50.0, "пружина должна удерживать шарик в разумных пределах, было " + maxDist);
}
}