diff --git a/TODO.md b/TODO.md index 7d005ea..8f5d03d 100644 --- a/TODO.md +++ b/TODO.md @@ -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/`. Коммит 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`). diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java new file mode 100644 index 0000000..f5ba4d7 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/command/impl/world/TierCommand.java @@ -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 [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"; + }; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/Balancer.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/Balancer.java new file mode 100644 index 0000000..3a60e04 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/misc/Balancer.java @@ -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())); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/Trinket.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/Trinket.java new file mode 100644 index 0000000..a6d33fd --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/items/Trinket.java @@ -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 gravity = num("gravity", 20.0f, 5.0f, 40.0f); + private final NumberValue weight = num("weight", 1.0f, 0.2f, 2.0f); + private final NumberValue friction = num("friction", 0.98f, 0.8f, 1.0f); + private final NumberValue bounce = num("bounce", 0.7f, 0.0f, 1.0f); + private final NumberValue groundFriction = num("ground_friction", 0.9f, 0.7f, 1.0f); + private final NumberValue tetherRadius = num("tether_radius", 1.2f, 0.3f, 3.0f); + private final NumberValue iconSize = num("icon_size", 0.28f, 0.1f, 0.6f); + + private final ModeValue colorMode = modeSetting("colorMode", "color_mode", "Static", AnimatedColorModeOptions.OPTIONS); + private final NumberValue 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 trailLength = visibleWhen(num("trail_length", 15, 5, TRAIL_CAPACITY), trailEnabled::get); + + private final BooleanValue soundEnabled = bool("sound_enabled", true); + private final NumberValue 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); + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/Chams.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/Chams.java index efe6c98..987ea38 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/Chams.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/features/module/modules/visuals/scene/Chams.java @@ -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 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; }; diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java index c645d4e..3c6baf7 100644 --- a/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/render/engine/pipeline/LoVisualRenderPipelines.java @@ -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. */ diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersCache.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersCache.java new file mode 100644 index 0000000..2ee844c --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersCache.java @@ -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/} возвращает + * {@code rankings..{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 CACHE = new ConcurrentHashMap<>(); + private static final Map 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 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 rankings, long fetchedAtMs) { + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersTier.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersTier.java new file mode 100644 index 0000000..5a98264 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/mctiers/MCTiersTier.java @@ -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; + } +} diff --git a/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/physics/TrinketPhysics.java b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/physics/TrinketPhysics.java new file mode 100644 index 0000000..22469a5 --- /dev/null +++ b/combatant-client-26.2/src/main/java/dev/loki/lovisual/util/physics/TrinketPhysics.java @@ -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); + } +} diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json index ca49666..79d3f1f 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/en_us.json @@ -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.", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json index 248fe00..4233840 100644 --- a/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/lang/ru_ru.json @@ -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, не отключая его.", diff --git a/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_chams_invert.frag b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_chams_invert.frag new file mode 100644 index 0000000..8c8da55 --- /dev/null +++ b/combatant-client-26.2/src/main/resources/assets/lovisual/shaders/hand_chams_invert.frag @@ -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); +} diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/mctiers/MCTiersTierTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/mctiers/MCTiersTierTest.java new file mode 100644 index 0000000..3ad1cd1 --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/mctiers/MCTiersTierTest.java @@ -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")); + } +} diff --git a/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/physics/TrinketPhysicsTest.java b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/physics/TrinketPhysicsTest.java new file mode 100644 index 0000000..caf54f1 --- /dev/null +++ b/combatant-client-26.2/src/test/java/dev/loki/lovisual/util/physics/TrinketPhysicsTest.java @@ -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); + } +}