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:
parent
4a73a70dbf
commit
25060c9fa9
14 changed files with 781 additions and 17 deletions
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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, не отключая его.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue