chore(history): squash 59 commit(s) from 2026-09-23
- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2) - refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2) - refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2) - refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2) - refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2) - refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2) - refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2) - refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2) - refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2) - refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2) - refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2) - refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2) - refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2) - refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2) - refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2) - refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2) - refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2) - refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2) - refactor(hud): Potions 617→176 + 4×≤166 (8.5.2) - refactor(hud): Admins 702→118 + 4×≤138 (8.5.2) - refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2) - refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2) - refactor(hud): Radar 392→161 + 2×≤124 (8.5.2) - refactor(hud): Fps 309→168 + 3×≤107 (8.5.2) - refactor(hud): Memory 304→151 + 3×≤144 (8.5.2) - refactor(hud): Tps 339→154 + 3×≤143 (8.5.2) - refactor(hud): Ping 309→145 + 3×≤125 (8.5.2) - refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2) - refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2) - refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2) - refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2) - refactor(hud): Armor 324→162 + 2×≤148 (8.5.2) - refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2) - docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост) - docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе) - refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2) - refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2) - refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2) - docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО - refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2) - docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата - docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка - docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3 - chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/ - docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1) - chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md - docs(backend): edition 2024 в плане вместо 2021 - chore: обновить версии до реально актуальных (проверено компиляцией) - docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md - docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend) - docs: commit messages in English from now on - chore: gitignore .superpowers/ scratch directory - docs: commit messages in English from now on (mod) - feat(backend): scaffold accounts-service with health check - chore(backend): remove target/ build artifacts from git, add gitignore - chore: track docs/ in git (was accidentally gitignored, never committed) - chore(backend): convert to Cargo workspace, plan full microservice layout - feat(backend): accounts/avatars/device_links schema + migration - feat(backend): argon2id password hashing
This commit is contained in:
parent
ec10395511
commit
9d08fa910a
2239 changed files with 35265 additions and 28110 deletions
75
mod/src/main/java/dev/loki/lovisual/util/FastFps.java
Normal file
75
mod/src/main/java/dev/loki/lovisual/util/FastFps.java
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public enum FastFps {
|
||||
;
|
||||
|
||||
private static final double NANOS_PER_SEC = 1_000_000_000.0;
|
||||
private static final long FPS_SMOOTHING_TAU_NANOS = 150_000_000L;
|
||||
private static final long MIN_FRAME_DISCONTINUITY_NANOS = 250_000_000L;
|
||||
private static final long INITIAL_FRAME_DISCONTINUITY_NANOS = 5_000_000_000L;
|
||||
private static final double FRAME_DISCONTINUITY_MULTIPLIER = 4.0;
|
||||
private static final float DEFAULT_DELTA_SECONDS = 1.0f / 60.0f;
|
||||
|
||||
private static long lastFrameNanos;
|
||||
private static double smoothedFrameNanos;
|
||||
private static float deltaSeconds = DEFAULT_DELTA_SECONDS;
|
||||
|
||||
@Getter
|
||||
private static int fps;
|
||||
|
||||
public static void onFrame() {
|
||||
long now = System.nanoTime();
|
||||
if (lastFrameNanos == 0L) {
|
||||
lastFrameNanos = now;
|
||||
return;
|
||||
}
|
||||
|
||||
long frameNanos = now - lastFrameNanos;
|
||||
lastFrameNanos = now;
|
||||
if (frameNanos <= 0L) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A minimized window, debugger stop or resource reload is not a rendered frame.
|
||||
// Feeding that gap into the FPS EMA poisons it for several seconds after resume.
|
||||
long discontinuityNanos = smoothedFrameNanos > 0.0
|
||||
? Math.max(MIN_FRAME_DISCONTINUITY_NANOS, (long) (smoothedFrameNanos * FRAME_DISCONTINUITY_MULTIPLIER))
|
||||
: INITIAL_FRAME_DISCONTINUITY_NANOS;
|
||||
if (frameNanos >= discontinuityNanos) {
|
||||
smoothedFrameNanos = 0.0;
|
||||
deltaSeconds = DEFAULT_DELTA_SECONDS;
|
||||
return;
|
||||
}
|
||||
|
||||
deltaSeconds = (float) (frameNanos / NANOS_PER_SEC);
|
||||
|
||||
if (smoothedFrameNanos == 0.0) {
|
||||
smoothedFrameNanos = frameNanos;
|
||||
} else {
|
||||
double alpha = 1.0 - Math.exp(-frameNanos / (double) FPS_SMOOTHING_TAU_NANOS);
|
||||
smoothedFrameNanos += (frameNanos - smoothedFrameNanos) * alpha;
|
||||
}
|
||||
|
||||
fps = (int) Math.round(NANOS_PER_SEC / smoothedFrameNanos);
|
||||
}
|
||||
|
||||
public static float getDeltaSeconds() {
|
||||
return deltaSeconds;
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
lastFrameNanos = 0L;
|
||||
smoothedFrameNanos = 0.0;
|
||||
deltaSeconds = DEFAULT_DELTA_SECONDS;
|
||||
fps = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
public enum LegacyHudNotifier {
|
||||
;
|
||||
|
||||
public static void show(String message) {
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
if (client.gui != null) {
|
||||
client.gui.hud.setOverlayMessage(Component.nullToEmpty(message), false);
|
||||
}
|
||||
}
|
||||
|
||||
public static void show(Component text) {
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
if (client.gui != null) {
|
||||
client.gui.hud.setOverlayMessage(text, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.NarratorStatus;
|
||||
import net.minecraft.client.Options;
|
||||
import dev.loki.lovisual.config.i18n.MainConfig;
|
||||
|
||||
//Да ну нахуй эту залупу блять
|
||||
public enum NarratorBlocker {
|
||||
;
|
||||
|
||||
private static boolean applying;
|
||||
|
||||
public static boolean isBlocked() {
|
||||
return MainConfig.get().isNarratorDisabled();
|
||||
}
|
||||
|
||||
public static void enforce(Minecraft client) {
|
||||
if (!isBlocked() || client == null) return;
|
||||
enforce(client.options);
|
||||
try {
|
||||
client.getNarrator().clear();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static void enforce(Options options) {
|
||||
if (!isBlocked() || options == null || applying) return;
|
||||
applying = true;
|
||||
try {
|
||||
if (options.narrator().get() != NarratorStatus.OFF) {
|
||||
options.narrator().set(NarratorStatus.OFF);
|
||||
}
|
||||
if (options.narratorHotkey().get()) {
|
||||
options.narratorHotkey().set(false);
|
||||
}
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
mod/src/main/java/dev/loki/lovisual/util/OmniItemUtils.java
Normal file
60
mod/src/main/java/dev/loki/lovisual/util/OmniItemUtils.java
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.item.*;
|
||||
import net.minecraft.world.item.enchantment.Enchantments;
|
||||
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
||||
|
||||
public enum OmniItemUtils {
|
||||
;
|
||||
|
||||
public static boolean isProjectileWeaponInHand(LocalPlayer player) {
|
||||
if (player == null) return false;
|
||||
|
||||
ItemStack chest = player.getItemBySlot(EquipmentSlot.CHEST);
|
||||
if (chest != null && chest.getItem() == Items.ELYTRA) {
|
||||
return true; // считаем как "есть снарядное оружие" → отключить omnisprint
|
||||
}
|
||||
|
||||
for (ItemStack stack : new ItemStack[]{
|
||||
player.getMainHandItem(),
|
||||
player.getOffhandItem()
|
||||
}) {
|
||||
if (stack.isEmpty()) continue;
|
||||
Item item = stack.getItem();
|
||||
|
||||
// ==== Bow ====
|
||||
if (item instanceof BowItem) return true;
|
||||
|
||||
// ==== Crossbow ====
|
||||
if (item instanceof CrossbowItem) return true;
|
||||
|
||||
// ==== Trident ====
|
||||
if (item == Items.TRIDENT) {
|
||||
ItemEnchantments ench =
|
||||
stack.getOrDefault(DataComponents.ENCHANTMENTS, ItemEnchantments.EMPTY);
|
||||
boolean hasRiptide = ench.keySet().stream().anyMatch(entry ->
|
||||
entry.unwrapKey().isPresent() &&
|
||||
entry.unwrapKey().get().equals(Enchantments.RIPTIDE)
|
||||
);
|
||||
if (!hasRiptide) return true;
|
||||
}
|
||||
|
||||
// ==== Throwable ====
|
||||
if (item == Items.ENDER_PEARL //||
|
||||
//item == Items.SNOWBALL ||
|
||||
/*item == Items.EGG*/) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 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.aiming;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
|
||||
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Finds a good aim point/rotation inside a target box.
|
||||
*/
|
||||
public enum AimPointUtil {
|
||||
;
|
||||
|
||||
public static RotationResult findBestRotation(Vec3 eyes,
|
||||
AABB box,
|
||||
float baseYaw,
|
||||
float basePitch,
|
||||
double range,
|
||||
double wallsRange,
|
||||
boolean allowThroughWalls) {
|
||||
if (eyes == null || box == null) return null;
|
||||
|
||||
List<Vec3> points = collectPoints(box);
|
||||
RotationResult bestVisible = null;
|
||||
RotationResult bestInvisible = null;
|
||||
|
||||
double rangeSq = range * range;
|
||||
double wallsSq = wallsRange * wallsRange;
|
||||
|
||||
for (Vec3 point : points) {
|
||||
double distSq = eyes.distanceToSqr(point);
|
||||
if (distSq > rangeSq && distSq > wallsSq) continue;
|
||||
|
||||
boolean visible = distSq <= wallsSq || RaycastUtil.hasLineOfSightPoint(eyes, point);
|
||||
if (!visible && !allowThroughWalls) continue;
|
||||
|
||||
float[] rot = RotationUtil.getRotations(eyes, point);
|
||||
float score = rotationScore(baseYaw, basePitch, rot[0], rot[1]);
|
||||
RotationResult candidate = new RotationResult(rot[0], rot[1], point, visible);
|
||||
|
||||
if (visible) {
|
||||
if (bestVisible == null || score < rotationScore(baseYaw, basePitch, bestVisible.yaw, bestVisible.pitch)) {
|
||||
bestVisible = candidate;
|
||||
}
|
||||
} else {
|
||||
if (bestInvisible == null || score < rotationScore(baseYaw, basePitch, bestInvisible.yaw, bestInvisible.pitch)) {
|
||||
bestInvisible = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestVisible != null ? bestVisible : bestInvisible;
|
||||
}
|
||||
|
||||
private static float rotationScore(float baseYaw, float basePitch, float yaw, float pitch) {
|
||||
float yawDiff = Math.abs(RotationUtil.angleDifference(baseYaw, yaw));
|
||||
float pitchDiff = Math.abs(RotationUtil.angleDifference(basePitch, pitch));
|
||||
return yawDiff + pitchDiff;
|
||||
}
|
||||
|
||||
private static List<Vec3> collectPoints(AABB box) {
|
||||
List<Vec3> out = new ArrayList<>(14);
|
||||
double minX = box.minX;
|
||||
double minY = box.minY;
|
||||
double minZ = box.minZ;
|
||||
double maxX = box.maxX;
|
||||
double maxY = box.maxY;
|
||||
double maxZ = box.maxZ;
|
||||
|
||||
double midX = (minX + maxX) * 0.5;
|
||||
double midY = (minY + maxY) * 0.5;
|
||||
double midZ = (minZ + maxZ) * 0.5;
|
||||
|
||||
// center + face centers
|
||||
out.add(new Vec3(midX, midY, midZ));
|
||||
out.add(new Vec3(midX, minY, midZ));
|
||||
out.add(new Vec3(midX, maxY, midZ));
|
||||
out.add(new Vec3(minX, midY, midZ));
|
||||
out.add(new Vec3(maxX, midY, midZ));
|
||||
out.add(new Vec3(midX, midY, minZ));
|
||||
out.add(new Vec3(midX, midY, maxZ));
|
||||
|
||||
// corners
|
||||
out.add(new Vec3(minX, minY, minZ));
|
||||
out.add(new Vec3(minX, minY, maxZ));
|
||||
out.add(new Vec3(minX, maxY, minZ));
|
||||
out.add(new Vec3(minX, maxY, maxZ));
|
||||
out.add(new Vec3(maxX, minY, minZ));
|
||||
out.add(new Vec3(maxX, minY, maxZ));
|
||||
out.add(new Vec3(maxX, maxY, minZ));
|
||||
out.add(new Vec3(maxX, maxY, maxZ));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
public record RotationResult(float yaw, float pitch, Vec3 point, boolean visible) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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.aiming;
|
||||
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.concurrent.PriorityBlockingQueue;
|
||||
|
||||
public final class RequestHandler<T> {
|
||||
|
||||
private final PriorityBlockingQueue<Request<T>> activeRequests =
|
||||
new PriorityBlockingQueue<>(11, Comparator.comparingInt((Request<T> req) -> req.priority).reversed());
|
||||
private int currentTick = 0;
|
||||
|
||||
private static boolean isProviderRunning(Object provider) {
|
||||
return !(provider instanceof Module module) || module.isEnabled();
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
currentTick++;
|
||||
}
|
||||
|
||||
public void request(Request<T> request) {
|
||||
if (request == null) return;
|
||||
|
||||
activeRequests.removeIf(existing -> existing.provider == request.provider);
|
||||
request.expiresIn += currentTick;
|
||||
activeRequests.add(request);
|
||||
}
|
||||
|
||||
public Request<T> getActiveRequest() {
|
||||
Request<T> top = activeRequests.peek();
|
||||
while (top != null && (top.expiresIn <= currentTick || !isProviderRunning(top.provider))) {
|
||||
activeRequests.poll();
|
||||
top = activeRequests.peek();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
public T getActiveRequestValue() {
|
||||
Request<T> top = getActiveRequest();
|
||||
return top != null ? top.value : null;
|
||||
}
|
||||
|
||||
public Object getActiveRequestProvider() {
|
||||
Request<T> top = getActiveRequest();
|
||||
return top != null ? top.provider : null;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
activeRequests.clear();
|
||||
currentTick = 0;
|
||||
}
|
||||
|
||||
public boolean clear(Object provider) {
|
||||
if (provider == null) {
|
||||
return false;
|
||||
}
|
||||
return activeRequests.removeIf(existing -> existing.provider == provider);
|
||||
}
|
||||
|
||||
public static final class Request<T> {
|
||||
private final int priority;
|
||||
private final Object provider;
|
||||
private final T value;
|
||||
private int expiresIn;
|
||||
|
||||
public Request(int expiresIn, int priority, Object provider, T value) {
|
||||
this.expiresIn = expiresIn;
|
||||
this.priority = priority;
|
||||
this.provider = provider;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public Object provider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public T value() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.aiming;
|
||||
|
||||
public final class RestrictedSingleUseAction {
|
||||
|
||||
private final Runnable action;
|
||||
private boolean used;
|
||||
|
||||
public RestrictedSingleUseAction(Runnable action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public void invoke() {
|
||||
if (used) return;
|
||||
used = true;
|
||||
if (action != null) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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.aiming.data;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
|
||||
public record Rotation(float yaw, float pitch, boolean isNormalized) {
|
||||
|
||||
public static final Rotation ZERO = new Rotation(0.0f, 0.0f, false);
|
||||
|
||||
public Rotation(float yaw, float pitch) {
|
||||
this(yaw, pitch, false);
|
||||
}
|
||||
|
||||
public static Rotation lookingAt(Vec3 point, Vec3 from) {
|
||||
return fromRotationVec(point.subtract(from));
|
||||
}
|
||||
|
||||
public static Rotation fromRotationVec(Vec3 lookVec) {
|
||||
return fromRotationVec(lookVec.x, lookVec.y, lookVec.z);
|
||||
}
|
||||
|
||||
public static Rotation fromRotationVec(double diffX, double diffY, double diffZ) {
|
||||
float yaw = Mth.wrapDegrees((float) Math.toDegrees(Math.atan2(diffZ, diffX)) - 90.0f);
|
||||
float pitch = Mth.wrapDegrees((float) -Math.toDegrees(Math.atan2(diffY, Math.hypot(diffX, diffZ))));
|
||||
return new Rotation(yaw, pitch, false);
|
||||
}
|
||||
|
||||
private static float clamp(float v, float low, float high) {
|
||||
if (v < low) return low;
|
||||
if (v > high) return high;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static float quantizeDelta(float rawDelta, float gcd) {
|
||||
if (gcd <= 0.0f) {
|
||||
return rawDelta;
|
||||
}
|
||||
|
||||
return Math.round(rawDelta / gcd) * gcd;
|
||||
}
|
||||
|
||||
public Vec3 directionVector() {
|
||||
return Vec3.directionFromRotation(pitch, yaw);
|
||||
}
|
||||
|
||||
public float xRot() {
|
||||
return pitch;
|
||||
}
|
||||
|
||||
public float yRot() {
|
||||
return yaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes GCD and modulo 360 at yaw.
|
||||
*/
|
||||
public Rotation normalize() {
|
||||
if (isNormalized) return this;
|
||||
|
||||
double gcd = RotationUtil.gcd();
|
||||
if (gcd <= 0.0) {
|
||||
float ny = yaw;
|
||||
float np = pitch;
|
||||
return new Rotation(ny, Mth.clamp(np, -90.0f, 90.0f), true);
|
||||
}
|
||||
|
||||
Rotation base = RotationManager.INSTANCE.getServerRotation();
|
||||
if (base == null || (!base.isNormalized() && base == Rotation.ZERO)) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc != null && mc.player != null) {
|
||||
base = new Rotation(mc.player.getYRot(), mc.player.getXRot(), true);
|
||||
} else {
|
||||
base = Rotation.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
RotationDelta diff = base.rotationDeltaTo(this);
|
||||
float g1 = quantizeDelta(diff.deltaYaw(), (float) gcd);
|
||||
float g2 = quantizeDelta(diff.deltaPitch(), (float) gcd);
|
||||
|
||||
float ny = base.yaw + g1;
|
||||
float np = base.pitch + g2;
|
||||
return new Rotation(ny, Mth.clamp(np, -90.0f, 90.0f), true);
|
||||
}
|
||||
|
||||
public float angleTo(Rotation other) {
|
||||
return Math.min(rotationDeltaTo(other).length(), 180.0f);
|
||||
}
|
||||
|
||||
public RotationDelta rotationDeltaTo(Rotation other) {
|
||||
return new RotationDelta(
|
||||
RotationUtil.angleDifference(other.yaw, this.yaw),
|
||||
RotationUtil.angleDifference(other.pitch, this.pitch)
|
||||
);
|
||||
}
|
||||
|
||||
public Rotation towardsLinear(Rotation other, float horizontalFactor, float verticalFactor) {
|
||||
RotationDelta diff = rotationDeltaTo(other);
|
||||
float rotationDifference = diff.length();
|
||||
if (rotationDifference <= 0.0f) {
|
||||
return this;
|
||||
}
|
||||
|
||||
float straightLineYaw = Math.abs(diff.deltaYaw() / rotationDifference) * horizontalFactor;
|
||||
float straightLinePitch = Math.abs(diff.deltaPitch() / rotationDifference) * verticalFactor;
|
||||
|
||||
return new Rotation(
|
||||
this.yaw + clamp(diff.deltaYaw(), -straightLineYaw, straightLineYaw),
|
||||
this.pitch + clamp(diff.deltaPitch(), -straightLinePitch, straightLinePitch)
|
||||
);
|
||||
}
|
||||
|
||||
public boolean approximatelyEquals(Rotation other) {
|
||||
return approximatelyEquals(other, 2.0f);
|
||||
}
|
||||
|
||||
public boolean approximatelyEquals(Rotation other, float tolerance) {
|
||||
if (other == null) return false;
|
||||
return angleTo(other) <= tolerance;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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.aiming.data;
|
||||
|
||||
public record RotationDelta(float deltaYaw, float deltaPitch) {
|
||||
|
||||
public float length() {
|
||||
return (float) Math.hypot(deltaYaw, deltaPitch);
|
||||
}
|
||||
|
||||
public net.minecraft.world.phys.Vec2 toVec2f() {
|
||||
return new net.minecraft.world.phys.Vec2(deltaYaw, deltaPitch);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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.aiming.data;
|
||||
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public record RotationWithVector(Rotation rotation, Vec3 vec) {
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.aiming.features;
|
||||
|
||||
import dev.loki.lovisual.config.values.mode.EnumValue;
|
||||
|
||||
public enum MovementCorrection implements EnumValue.IdProvider {
|
||||
OFF("Off"),
|
||||
STRICT("Strict"),
|
||||
SILENT("Silent");
|
||||
|
||||
private final String id;
|
||||
|
||||
MovementCorrection(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* 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.aiming.features.processors;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
public final class FailRotationProcessor implements RotationProcessor {
|
||||
|
||||
private static final int MIN_TRANSITION_TICKS = 8;
|
||||
private static final int UNSAFE_COOLDOWN_TICKS = 4;
|
||||
private static final float MAX_SHIFT_STEP = 1.15f;
|
||||
private static final Rotation ZERO_SHIFT = new Rotation(0f, 0f);
|
||||
|
||||
public final BooleanValue enabled;
|
||||
private final NumberValue<Integer> failRate;
|
||||
private final NumberValue<Float> failFactor;
|
||||
private final NumberValue<Float> strengthHorizontalMin;
|
||||
private final NumberValue<Float> strengthHorizontalMax;
|
||||
private final NumberValue<Float> strengthVerticalMin;
|
||||
private final NumberValue<Float> strengthVerticalMax;
|
||||
private final NumberValue<Integer> transitionMin;
|
||||
private final NumberValue<Integer> transitionMax;
|
||||
private final BooleanSupplier unsafeWindow;
|
||||
|
||||
private int ticksElapsed = Integer.MAX_VALUE;
|
||||
private int currentTransitionDuration = 1;
|
||||
private int cooldownTicks;
|
||||
private Rotation shiftRotation = ZERO_SHIFT;
|
||||
private Rotation appliedShift = ZERO_SHIFT;
|
||||
|
||||
public FailRotationProcessor(BooleanValue enabled,
|
||||
NumberValue<Integer> failRate,
|
||||
NumberValue<Float> failFactor,
|
||||
NumberValue<Float> strengthHorizontalMin,
|
||||
NumberValue<Float> strengthHorizontalMax,
|
||||
NumberValue<Float> strengthVerticalMin,
|
||||
NumberValue<Float> strengthVerticalMax,
|
||||
NumberValue<Integer> transitionMin,
|
||||
NumberValue<Integer> transitionMax) {
|
||||
this(enabled, failRate, failFactor, strengthHorizontalMin, strengthHorizontalMax,
|
||||
strengthVerticalMin, strengthVerticalMax, transitionMin, transitionMax, () -> false);
|
||||
}
|
||||
|
||||
public FailRotationProcessor(BooleanValue enabled,
|
||||
NumberValue<Integer> failRate,
|
||||
NumberValue<Float> failFactor,
|
||||
NumberValue<Float> strengthHorizontalMin,
|
||||
NumberValue<Float> strengthHorizontalMax,
|
||||
NumberValue<Float> strengthVerticalMin,
|
||||
NumberValue<Float> strengthVerticalMax,
|
||||
NumberValue<Integer> transitionMin,
|
||||
NumberValue<Integer> transitionMax,
|
||||
BooleanSupplier unsafeWindow) {
|
||||
this.enabled = enabled;
|
||||
this.failRate = failRate;
|
||||
this.failFactor = failFactor;
|
||||
this.strengthHorizontalMin = strengthHorizontalMin;
|
||||
this.strengthHorizontalMax = strengthHorizontalMax;
|
||||
this.strengthVerticalMin = strengthVerticalMin;
|
||||
this.strengthVerticalMax = strengthVerticalMax;
|
||||
this.transitionMin = transitionMin;
|
||||
this.transitionMax = transitionMax;
|
||||
this.unsafeWindow = unsafeWindow != null ? unsafeWindow : () -> false;
|
||||
}
|
||||
|
||||
private static Rotation stepTowards(Rotation current, Rotation target, float maxStep) {
|
||||
return new Rotation(
|
||||
step(current.yaw(), target.yaw(), maxStep),
|
||||
step(current.pitch(), target.pitch(), maxStep)
|
||||
);
|
||||
}
|
||||
|
||||
private static float step(float current, float target, float maxStep) {
|
||||
float diff = target - current;
|
||||
if (Math.abs(diff) <= maxStep) {
|
||||
return target;
|
||||
}
|
||||
return current + Math.copySign(maxStep, diff);
|
||||
}
|
||||
|
||||
private static boolean isZero(Rotation rotation) {
|
||||
return Math.abs(rotation.yaw()) < 0.001f && Math.abs(rotation.pitch()) < 0.001f;
|
||||
}
|
||||
|
||||
private static int random(int min, int max) {
|
||||
if (max <= min) return min;
|
||||
return ThreadLocalRandom.current().nextInt(min, max + 1);
|
||||
}
|
||||
|
||||
private static float randomRange(float a, float b) {
|
||||
float min = Math.min(a, b);
|
||||
float max = Math.max(a, b);
|
||||
if (max <= min) return min;
|
||||
return (float) ThreadLocalRandom.current().nextDouble(min, max);
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
if (!enabled.get()) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cooldownTicks > 0) {
|
||||
cooldownTicks--;
|
||||
}
|
||||
|
||||
boolean unsafe = unsafeWindow.getAsBoolean();
|
||||
if (unsafe) {
|
||||
cooldownTicks = Math.max(cooldownTicks, UNSAFE_COOLDOWN_TICKS);
|
||||
if (isInFailState() && ticksElapsed < currentTransitionDuration / 2) {
|
||||
ticksElapsed = currentTransitionDuration / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (isInFailState()) {
|
||||
ticksElapsed++;
|
||||
if (!isInFailState()) {
|
||||
cooldownTicks = Math.max(cooldownTicks, UNSAFE_COOLDOWN_TICKS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (unsafe || cooldownTicks > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
int chance = ThreadLocalRandom.current().nextInt(0, 101);
|
||||
if (failRate.get() > chance) {
|
||||
currentTransitionDuration = Math.max(MIN_TRANSITION_TICKS, random(transitionMin.get(), transitionMax.get()) * 2);
|
||||
|
||||
float yawShift = randomRange(strengthHorizontalMin.get(), strengthHorizontalMax.get());
|
||||
if (ThreadLocalRandom.current().nextBoolean()) yawShift = -yawShift;
|
||||
|
||||
float pitchShift = randomRange(strengthVerticalMin.get(), strengthVerticalMax.get());
|
||||
if (ThreadLocalRandom.current().nextBoolean()) pitchShift = -pitchShift;
|
||||
|
||||
shiftRotation = new Rotation(yawShift, pitchShift);
|
||||
ticksElapsed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isInFailState() {
|
||||
return enabled.get() && ticksElapsed < currentTransitionDuration;
|
||||
}
|
||||
|
||||
@EventHandler(priority = 1000)
|
||||
public void onGameTick(GameTickEvent event) {
|
||||
tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
if (!enabled.get() && isZero(appliedShift)) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
Rotation desiredShift = ZERO_SHIFT;
|
||||
|
||||
if (enabled.get()
|
||||
&& isInFailState()
|
||||
&& !unsafeWindow.getAsBoolean()
|
||||
&& currentRotation.angleTo(targetRotation) <= 8.0f) {
|
||||
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
|
||||
if (prevRotation != null) {
|
||||
Rotation serverRotation = RotationManager.INSTANCE.getServerRotation();
|
||||
|
||||
float deltaYaw = (prevRotation.yaw() - serverRotation.yaw()) * failFactor.get();
|
||||
float deltaPitch = (prevRotation.pitch() - serverRotation.pitch()) * failFactor.get();
|
||||
float pulse = pulse();
|
||||
|
||||
desiredShift = new Rotation(
|
||||
(deltaYaw + shiftRotation.yaw()) * pulse,
|
||||
(deltaPitch + shiftRotation.pitch()) * pulse
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
appliedShift = stepTowards(appliedShift, desiredShift, MAX_SHIFT_STEP);
|
||||
if (isZero(appliedShift)) {
|
||||
return targetRotation;
|
||||
}
|
||||
return new Rotation(
|
||||
targetRotation.yaw() + appliedShift.yaw(),
|
||||
targetRotation.pitch() + appliedShift.pitch()
|
||||
);
|
||||
}
|
||||
|
||||
private float pulse() {
|
||||
if (currentTransitionDuration <= 0) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
float progress = Math.min(1.0f, Math.max(0.0f, (ticksElapsed + 1.0f) / currentTransitionDuration));
|
||||
float sine = (float) Math.sin(progress * Math.PI);
|
||||
return sine * sine;
|
||||
}
|
||||
|
||||
private void resetState() {
|
||||
ticksElapsed = Integer.MAX_VALUE;
|
||||
cooldownTicks = 0;
|
||||
shiftRotation = ZERO_SHIFT;
|
||||
appliedShift = ZERO_SHIFT;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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.aiming.features.processors;
|
||||
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
public interface RotationProcessor {
|
||||
|
||||
Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth;
|
||||
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
public abstract class AngleSmooth implements RotationProcessor {
|
||||
|
||||
private final String name;
|
||||
|
||||
protected AngleSmooth(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public abstract int calculateTicks(Rotation currentRotation, Rotation targetRotation);
|
||||
|
||||
@Override
|
||||
public abstract Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth;
|
||||
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
public abstract class FactorAngleSmooth extends AngleSmooth implements RotationProcessor {
|
||||
|
||||
protected FactorAngleSmooth(String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
protected abstract float[] calculateFactors(RotationTarget rotationTarget,
|
||||
Rotation currentRotation,
|
||||
Rotation targetRotation);
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
float[] factors = calculateFactors(rotationTarget, currentRotation, targetRotation);
|
||||
Rotation stepped = currentRotation.towardsLinear(targetRotation, factors[0], factors[1]);
|
||||
float gcd = (float) RotationUtil.gcd();
|
||||
float snapTolerance = Math.max(gcd * 2.0f, 0.5f);
|
||||
if (stepped.angleTo(targetRotation) <= snapTolerance) {
|
||||
return targetRotation;
|
||||
}
|
||||
return stepped;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
|
||||
Rotation rot = currentRotation;
|
||||
int ticks = -1;
|
||||
do {
|
||||
float[] factors = calculateFactors(null, rot, targetRotation);
|
||||
rot = rot.towardsLinear(targetRotation, factors[0], factors[1]);
|
||||
ticks++;
|
||||
} while (!rot.approximatelyEquals(targetRotation) && ticks < 80);
|
||||
return ticks;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth;
|
||||
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
public final class NoneAngleSmooth extends AngleSmooth {
|
||||
|
||||
public NoneAngleSmooth() {
|
||||
super("None");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
return currentRotation;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth.impl;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.data.RotationDelta;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class AccelerationAngleSmooth extends AngleSmooth {
|
||||
|
||||
// Dynamic acceleration
|
||||
public final BooleanValue dynamicAccelEnabled;
|
||||
public final NumberValue<Float> coefDistance;
|
||||
// Acceleration error
|
||||
public final BooleanValue accelErrorEnabled;
|
||||
// Constant error
|
||||
public final BooleanValue constantErrorEnabled;
|
||||
// Sigmoid deceleration
|
||||
public final BooleanValue sigmoidDecelEnabled;
|
||||
private final NumberValue<Float> yawAccelerationMin;
|
||||
private final NumberValue<Float> yawAccelerationMax;
|
||||
private final NumberValue<Float> pitchAccelerationMin;
|
||||
private final NumberValue<Float> pitchAccelerationMax;
|
||||
private final NumberValue<Float> yawCrosshairMin;
|
||||
private final NumberValue<Float> yawCrosshairMax;
|
||||
private final NumberValue<Float> pitchCrosshairMin;
|
||||
private final NumberValue<Float> pitchCrosshairMax;
|
||||
private final NumberValue<Float> yawAccelError;
|
||||
private final NumberValue<Float> pitchAccelError;
|
||||
private final NumberValue<Float> yawConstantError;
|
||||
private final NumberValue<Float> pitchConstantError;
|
||||
private final NumberValue<Float> sigmoidSteepness;
|
||||
private final NumberValue<Float> sigmoidMidpoint;
|
||||
|
||||
public AccelerationAngleSmooth(
|
||||
NumberValue<Float> yawAccelerationMin,
|
||||
NumberValue<Float> yawAccelerationMax,
|
||||
NumberValue<Float> pitchAccelerationMin,
|
||||
NumberValue<Float> pitchAccelerationMax,
|
||||
BooleanValue dynamicAccelEnabled,
|
||||
NumberValue<Float> coefDistance,
|
||||
NumberValue<Float> yawCrosshairMin,
|
||||
NumberValue<Float> yawCrosshairMax,
|
||||
NumberValue<Float> pitchCrosshairMin,
|
||||
NumberValue<Float> pitchCrosshairMax,
|
||||
BooleanValue accelErrorEnabled,
|
||||
NumberValue<Float> yawAccelError,
|
||||
NumberValue<Float> pitchAccelError,
|
||||
BooleanValue constantErrorEnabled,
|
||||
NumberValue<Float> yawConstantError,
|
||||
NumberValue<Float> pitchConstantError,
|
||||
BooleanValue sigmoidDecelEnabled,
|
||||
NumberValue<Float> sigmoidSteepness,
|
||||
NumberValue<Float> sigmoidMidpoint
|
||||
) {
|
||||
super("Acceleration");
|
||||
this.yawAccelerationMin = yawAccelerationMin;
|
||||
this.yawAccelerationMax = yawAccelerationMax;
|
||||
this.pitchAccelerationMin = pitchAccelerationMin;
|
||||
this.pitchAccelerationMax = pitchAccelerationMax;
|
||||
this.dynamicAccelEnabled = dynamicAccelEnabled;
|
||||
this.coefDistance = coefDistance;
|
||||
this.yawCrosshairMin = yawCrosshairMin;
|
||||
this.yawCrosshairMax = yawCrosshairMax;
|
||||
this.pitchCrosshairMin = pitchCrosshairMin;
|
||||
this.pitchCrosshairMax = pitchCrosshairMax;
|
||||
this.accelErrorEnabled = accelErrorEnabled;
|
||||
this.yawAccelError = yawAccelError;
|
||||
this.pitchAccelError = pitchAccelError;
|
||||
this.constantErrorEnabled = constantErrorEnabled;
|
||||
this.yawConstantError = yawConstantError;
|
||||
this.pitchConstantError = pitchConstantError;
|
||||
this.sigmoidDecelEnabled = sigmoidDecelEnabled;
|
||||
this.sigmoidSteepness = sigmoidSteepness;
|
||||
this.sigmoidMidpoint = sigmoidMidpoint;
|
||||
}
|
||||
|
||||
private static float calculateAcceleration(float diff, float prevDiff,
|
||||
float low, float high,
|
||||
float decel) {
|
||||
float delta = RotationUtil.angleDifference(diff, prevDiff);
|
||||
float clamped = clamp(delta, low, high);
|
||||
return clamped * decel;
|
||||
}
|
||||
|
||||
private static float clamp(float v, float low, float high) {
|
||||
if (v < low) return low;
|
||||
if (v > high) return high;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static float randomRange(float a, float b) {
|
||||
float min = Math.min(a, b);
|
||||
float max = Math.max(a, b);
|
||||
if (max <= min) return min;
|
||||
return (float) ThreadLocalRandom.current().nextDouble(min, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
|
||||
if (prevRotation == null) {
|
||||
prevRotation = RotationManager.getPlayerLastRotation();
|
||||
}
|
||||
|
||||
RotationDelta prevDiff = prevRotation.rotationDeltaTo(currentRotation);
|
||||
RotationDelta diff = currentRotation.rotationDeltaTo(targetRotation);
|
||||
|
||||
double distance = 0.0;
|
||||
boolean crosshair = false;
|
||||
if (rotationTarget != null && rotationTarget.entity != null) {
|
||||
distance = RotationManager.boxedDistanceToPlayer(rotationTarget.entity);
|
||||
crosshair = RaycastUtil.isLookingAtEntity(
|
||||
RotationManager.player(),
|
||||
rotationTarget.entity,
|
||||
currentRotation.yaw(),
|
||||
currentRotation.pitch(),
|
||||
Math.max(3.0, distance),
|
||||
0.0
|
||||
) != null;
|
||||
}
|
||||
|
||||
float[] newDiff = computeTurnSpeed(prevDiff, diff, crosshair, distance);
|
||||
return new Rotation(currentRotation.yaw() + newDiff[0], currentRotation.pitch() + newDiff[1]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
|
||||
Rotation prevRotation = RotationManager.INSTANCE.getPreviousRotation();
|
||||
if (prevRotation == null) {
|
||||
prevRotation = RotationManager.getPlayerLastRotation();
|
||||
}
|
||||
|
||||
RotationDelta prevDiff = prevRotation.rotationDeltaTo(currentRotation);
|
||||
RotationDelta diff = currentRotation.rotationDeltaTo(targetRotation);
|
||||
|
||||
if (diff.deltaYaw() == 0f && diff.deltaPitch() == 0f) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
float[] newDiff = computeTurnSpeed(prevDiff, diff, false, 0.0);
|
||||
|
||||
if ((newDiff[0] == 0f && newDiff[1] == 0f) ||
|
||||
(Math.abs(diff.deltaYaw()) < Math.abs(newDiff[0]) &&
|
||||
Math.abs(diff.deltaPitch()) < Math.abs(newDiff[1]))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
double ticksH = Math.floor(Math.abs(diff.deltaYaw()) / Math.abs(newDiff[0]));
|
||||
double ticksV = Math.floor(Math.abs(diff.deltaPitch()) / Math.abs(newDiff[1]));
|
||||
if (Double.isNaN(ticksH) || Double.isNaN(ticksV)) return 0;
|
||||
return (int) Math.max(ticksH, ticksV);
|
||||
}
|
||||
|
||||
private float[] computeTurnSpeed(RotationDelta prevDiff,
|
||||
RotationDelta diff,
|
||||
boolean crosshair,
|
||||
double distance) {
|
||||
float decelerationFactor = 1.0f;
|
||||
if (sigmoidDecelEnabled.get()) {
|
||||
decelerationFactor = computeDecelerationFactor(diff.length());
|
||||
}
|
||||
|
||||
boolean crosshairCheck = dynamicAccelEnabled.get() && crosshair;
|
||||
float distanceFactor = (coefDistance.get() * (float) distance);
|
||||
|
||||
float yawAccel = randomRange(yawAccelerationMin.get(), yawAccelerationMax.get());
|
||||
float pitchAccel = randomRange(pitchAccelerationMin.get(), pitchAccelerationMax.get());
|
||||
|
||||
if (crosshairCheck) {
|
||||
yawAccel = randomRange(yawCrosshairMin.get(), yawCrosshairMax.get());
|
||||
pitchAccel = randomRange(pitchCrosshairMin.get(), pitchCrosshairMax.get());
|
||||
}
|
||||
|
||||
float yawLow = -yawAccel + distanceFactor;
|
||||
float yawHigh = yawAccel + distanceFactor;
|
||||
float pitchLow = -pitchAccel + distanceFactor;
|
||||
float pitchHigh = pitchAccel + distanceFactor;
|
||||
|
||||
float yawA = calculateAcceleration(diff.deltaYaw(), prevDiff.deltaYaw(),
|
||||
yawLow, yawHigh, decelerationFactor);
|
||||
float pitchA = calculateAcceleration(diff.deltaPitch(), prevDiff.deltaPitch(),
|
||||
pitchLow, pitchHigh, decelerationFactor);
|
||||
|
||||
float[] errors = getErrors(yawA, pitchA);
|
||||
return new float[]{
|
||||
prevDiff.deltaYaw() + yawA + errors[0],
|
||||
prevDiff.deltaPitch() + pitchA + errors[1]
|
||||
};
|
||||
}
|
||||
|
||||
private float computeDecelerationFactor(float rotationDifference) {
|
||||
float scaled = rotationDifference / 120f;
|
||||
double sigmoid = 1.0 / (1.0 + Math.exp(-(sigmoidSteepness.get() * (scaled - sigmoidMidpoint.get()))));
|
||||
float out = (float) sigmoid;
|
||||
if (out < 0f) out = 0f;
|
||||
if (out > 180f) out = 180f;
|
||||
return out;
|
||||
}
|
||||
|
||||
private float[] getErrors(float yawAccel, float pitchAccel) {
|
||||
float yawErr = 0f;
|
||||
float pitchErr = 0f;
|
||||
|
||||
if (accelErrorEnabled.get()) {
|
||||
float yawAcc = yawAccelError.get();
|
||||
float pitchAcc = pitchAccelError.get();
|
||||
yawErr += yawAccel * randomRange(-yawAcc, yawAcc);
|
||||
pitchErr += pitchAccel * randomRange(-pitchAcc, pitchAcc);
|
||||
}
|
||||
|
||||
if (constantErrorEnabled.get()) {
|
||||
float yawConst = yawConstantError.get();
|
||||
float pitchConst = pitchConstantError.get();
|
||||
yawErr += randomRange(-yawConst, yawConst);
|
||||
pitchErr += randomRange(-pitchConst, pitchConst);
|
||||
}
|
||||
|
||||
return new float[]{yawErr, pitchErr};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth.impl;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class InterpolationAngleSmooth extends FactorAngleSmooth {
|
||||
|
||||
private final NumberValue<Integer> horizontalMin;
|
||||
private final NumberValue<Integer> horizontalMax;
|
||||
private final NumberValue<Integer> verticalMin;
|
||||
private final NumberValue<Integer> verticalMax;
|
||||
private final NumberValue<Integer> directionChangeMin;
|
||||
private final NumberValue<Integer> directionChangeMax;
|
||||
private final NumberValue<Float> midpoint;
|
||||
|
||||
public InterpolationAngleSmooth(NumberValue<Integer> horizontalMin,
|
||||
NumberValue<Integer> horizontalMax,
|
||||
NumberValue<Integer> verticalMin,
|
||||
NumberValue<Integer> verticalMax,
|
||||
NumberValue<Integer> directionChangeMin,
|
||||
NumberValue<Integer> directionChangeMax,
|
||||
NumberValue<Float> midpoint) {
|
||||
super("Interpolation");
|
||||
this.horizontalMin = horizontalMin;
|
||||
this.horizontalMax = horizontalMax;
|
||||
this.verticalMin = verticalMin;
|
||||
this.verticalMax = verticalMax;
|
||||
this.directionChangeMin = directionChangeMin;
|
||||
this.directionChangeMax = directionChangeMax;
|
||||
this.midpoint = midpoint;
|
||||
}
|
||||
|
||||
private static float calculateFactor(float rotationDifference, float turnSpeed, float directionChange,
|
||||
float midpoint) {
|
||||
float t = clamp01(rotationDifference / 180f);
|
||||
float bezierSpeed = bezierTransform(0.05f, 1f, 1f - t);
|
||||
float sigmoidSpeed = sigmoidTransform(t);
|
||||
if (t > midpoint) {
|
||||
return bezierSpeed * turnSpeed;
|
||||
}
|
||||
return sigmoidSpeed * clamp01(turnSpeed + directionChange);
|
||||
}
|
||||
|
||||
private static float sigmoidTransform(float t) {
|
||||
return (float) (1f / (1f + Math.exp(-0.5f * (t - 0.3f))));
|
||||
}
|
||||
|
||||
private static float bezierTransform(float start, float end, float t) {
|
||||
return (1f - t) * (1f - t) * start + 2f * (1f - t) * t * 1f + t * t * end;
|
||||
}
|
||||
|
||||
private static float clamp01(float v) {
|
||||
return Math.max(0f, Math.min(1f, v));
|
||||
}
|
||||
|
||||
private static float random(int min, int max) {
|
||||
if (max <= min) return min;
|
||||
return (float) ThreadLocalRandom.current().nextInt(min, max + 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected float[] calculateFactors(RotationTarget rotationTarget,
|
||||
Rotation currentRotation,
|
||||
Rotation targetRotation) {
|
||||
float yawDiff = RotationUtil.wrapDegrees(targetRotation.yaw() - currentRotation.yaw());
|
||||
float pitchDiff = RotationUtil.wrapDegrees(targetRotation.pitch() - currentRotation.pitch());
|
||||
|
||||
float directionChange = 0f;
|
||||
RotationTarget prev = RotationManager.INSTANCE.getPreviousRotationTarget();
|
||||
if (rotationTarget != null && prev != null) {
|
||||
int dMin = Math.min(directionChangeMin.get(), directionChangeMax.get());
|
||||
int dMax = Math.max(directionChangeMin.get(), directionChangeMax.get());
|
||||
float factor = random(dMin, dMax) / 100.0f;
|
||||
float diff = prev.rotation.angleTo(targetRotation);
|
||||
directionChange = clamp01(diff) * factor;
|
||||
}
|
||||
|
||||
int hMin = Math.min(horizontalMin.get(), horizontalMax.get());
|
||||
int hMax = Math.max(horizontalMin.get(), horizontalMax.get());
|
||||
int vMin = Math.min(verticalMin.get(), verticalMax.get());
|
||||
int vMax = Math.max(verticalMin.get(), verticalMax.get());
|
||||
|
||||
float horizontalSpeed = (rotationTarget != null ? random(hMin, hMax) : hMin) / 100.0f;
|
||||
float verticalSpeed = (rotationTarget != null ? random(vMin, vMax) : vMin) / 100.0f;
|
||||
|
||||
float hFactor = calculateFactor(Math.abs(yawDiff), clamp01(horizontalSpeed), directionChange, midpoint.get());
|
||||
float vFactor = calculateFactor(Math.abs(pitchDiff), clamp01(verticalSpeed), directionChange, midpoint.get());
|
||||
|
||||
return new float[]{hFactor * Math.abs(yawDiff), vFactor * Math.abs(pitchDiff)};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth.impl;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class LinearAngleSmooth extends FactorAngleSmooth {
|
||||
|
||||
private final NumberValue<Float> horizontalMin;
|
||||
private final NumberValue<Float> horizontalMax;
|
||||
private final NumberValue<Float> verticalMin;
|
||||
private final NumberValue<Float> verticalMax;
|
||||
private float currentHorizontalFactor = -1.0f;
|
||||
private float currentVerticalFactor = -1.0f;
|
||||
private float targetHorizontalFactor = -1.0f;
|
||||
private float targetVerticalFactor = -1.0f;
|
||||
private int trackedEntityId = Integer.MIN_VALUE;
|
||||
|
||||
public LinearAngleSmooth(NumberValue<Float> horizontalMin,
|
||||
NumberValue<Float> horizontalMax,
|
||||
NumberValue<Float> verticalMin,
|
||||
NumberValue<Float> verticalMax) {
|
||||
super("Linear");
|
||||
this.horizontalMin = horizontalMin;
|
||||
this.horizontalMax = horizontalMax;
|
||||
this.verticalMin = verticalMin;
|
||||
this.verticalMax = verticalMax;
|
||||
}
|
||||
|
||||
private static float pickFactor(float min, float max, float angle) {
|
||||
if (max <= min) return min;
|
||||
float normalized = Math.max(0.0f, Math.min(1.0f, angle / 90.0f));
|
||||
float center = lerp(min, max, normalized);
|
||||
float spread = Math.max(1.0f, (max - min) * 0.12f);
|
||||
return clamp(random(center - spread, center + spread), min, max);
|
||||
}
|
||||
|
||||
private static float lerp(float from, float to, float factor) {
|
||||
return from + (to - from) * factor;
|
||||
}
|
||||
|
||||
private static float clamp(float value, float min, float max) {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float random(float min, float max) {
|
||||
if (max <= min) return min;
|
||||
return (float) (ThreadLocalRandom.current().nextDouble(min, max));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected float[] calculateFactors(RotationTarget rotationTarget,
|
||||
Rotation currentRotation,
|
||||
Rotation targetRotation) {
|
||||
float hMin = Math.min(horizontalMin.get(), horizontalMax.get());
|
||||
float hMax = Math.max(horizontalMin.get(), horizontalMax.get());
|
||||
float vMin = Math.min(verticalMin.get(), verticalMax.get());
|
||||
float vMax = Math.max(verticalMin.get(), verticalMax.get());
|
||||
|
||||
if (rotationTarget == null) {
|
||||
resetState();
|
||||
return new float[]{hMin, vMin};
|
||||
}
|
||||
|
||||
int entityId = rotationTarget.entity != null ? rotationTarget.entity.getId() : Integer.MIN_VALUE;
|
||||
if (trackedEntityId != entityId) {
|
||||
trackedEntityId = entityId;
|
||||
currentHorizontalFactor = random(hMin, hMax);
|
||||
currentVerticalFactor = random(vMin, vMax);
|
||||
targetHorizontalFactor = currentHorizontalFactor;
|
||||
targetVerticalFactor = currentVerticalFactor;
|
||||
}
|
||||
|
||||
float angle = currentRotation.angleTo(targetRotation);
|
||||
if (Math.abs(currentHorizontalFactor - targetHorizontalFactor) < 0.5f) {
|
||||
targetHorizontalFactor = pickFactor(hMin, hMax, angle);
|
||||
}
|
||||
if (Math.abs(currentVerticalFactor - targetVerticalFactor) < 0.5f) {
|
||||
targetVerticalFactor = pickFactor(vMin, vMax, angle);
|
||||
}
|
||||
|
||||
float blend = angle > 45.0f ? 0.35f : angle > 12.0f ? 0.22f : 0.14f;
|
||||
currentHorizontalFactor = lerp(currentHorizontalFactor, targetHorizontalFactor, blend);
|
||||
currentVerticalFactor = lerp(currentVerticalFactor, targetVerticalFactor, blend);
|
||||
return new float[]{currentHorizontalFactor, currentVerticalFactor};
|
||||
}
|
||||
|
||||
private void resetState() {
|
||||
trackedEntityId = Integer.MIN_VALUE;
|
||||
currentHorizontalFactor = -1.0f;
|
||||
currentVerticalFactor = -1.0f;
|
||||
targetHorizontalFactor = -1.0f;
|
||||
targetVerticalFactor = -1.0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.util.aiming.features.processors.anglesmooth.impl.curves;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.FactorAngleSmooth;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class SigmoidAngleSmooth extends FactorAngleSmooth {
|
||||
|
||||
private final NumberValue<Float> horizontalMin;
|
||||
private final NumberValue<Float> horizontalMax;
|
||||
private final NumberValue<Float> verticalMin;
|
||||
private final NumberValue<Float> verticalMax;
|
||||
private final NumberValue<Float> steepness;
|
||||
private final NumberValue<Float> midpoint;
|
||||
|
||||
public SigmoidAngleSmooth(NumberValue<Float> horizontalMin,
|
||||
NumberValue<Float> horizontalMax,
|
||||
NumberValue<Float> verticalMin,
|
||||
NumberValue<Float> verticalMax,
|
||||
NumberValue<Float> steepness,
|
||||
NumberValue<Float> midpoint) {
|
||||
super("Sigmoid");
|
||||
this.horizontalMin = horizontalMin;
|
||||
this.horizontalMax = horizontalMax;
|
||||
this.verticalMin = verticalMin;
|
||||
this.verticalMax = verticalMax;
|
||||
this.steepness = steepness;
|
||||
this.midpoint = midpoint;
|
||||
}
|
||||
|
||||
private static float random(float min, float max) {
|
||||
if (max <= min) return min;
|
||||
return (float) (ThreadLocalRandom.current().nextDouble(min, max));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected float[] calculateFactors(RotationTarget rotationTarget,
|
||||
Rotation currentRotation,
|
||||
Rotation targetRotation) {
|
||||
float rotationDifference = currentRotation.angleTo(targetRotation);
|
||||
|
||||
float hMin = Math.min(horizontalMin.get(), horizontalMax.get());
|
||||
float hMax = Math.max(horizontalMin.get(), horizontalMax.get());
|
||||
float vMin = Math.min(verticalMin.get(), verticalMax.get());
|
||||
float vMax = Math.max(verticalMin.get(), verticalMax.get());
|
||||
|
||||
float hSpeed = rotationTarget != null ? random(hMin, hMax) : hMin;
|
||||
float vSpeed = rotationTarget != null ? random(vMin, vMax) : vMin;
|
||||
|
||||
float hFactor = computeFactor(rotationDifference, hSpeed);
|
||||
float vFactor = computeFactor(rotationDifference, vSpeed);
|
||||
return new float[]{hFactor, vFactor};
|
||||
}
|
||||
|
||||
private float computeFactor(float rotationDifference, float turnSpeed) {
|
||||
float scaled = rotationDifference / 120f;
|
||||
double sigmoid = 1.0 / (1.0 + Math.exp(-steepness.get() * (scaled - midpoint.get())));
|
||||
float interpolated = (float) (sigmoid * turnSpeed);
|
||||
if (interpolated < 0f) interpolated = 0f;
|
||||
if (interpolated > 180f) interpolated = 180f;
|
||||
return interpolated;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth.impl.curves;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Practical hybrid smooth with distance-aware speed and optional micro-jitter.
|
||||
* Designed to reach target reliably while keeping motion less robotic.
|
||||
*/
|
||||
public final class SmartAngleSmooth extends AngleSmooth {
|
||||
|
||||
private final NumberValue<Float> yawMin;
|
||||
private final NumberValue<Float> yawMax;
|
||||
private final NumberValue<Float> pitchMin;
|
||||
private final NumberValue<Float> pitchMax;
|
||||
private final NumberValue<Float> snapThreshold;
|
||||
private final NumberValue<Float> jitterYaw;
|
||||
private final NumberValue<Float> jitterPitch;
|
||||
private final BooleanValue decelerateEnabled;
|
||||
private final NumberValue<Float> decelerateAngle;
|
||||
private final NumberValue<Float> decelerateMinFactor;
|
||||
private int trackedEntityId = Integer.MIN_VALUE;
|
||||
private Rotation virtualRotation;
|
||||
private boolean wasResetting;
|
||||
private int resetTicks;
|
||||
|
||||
public SmartAngleSmooth(NumberValue<Float> yawMin,
|
||||
NumberValue<Float> yawMax,
|
||||
NumberValue<Float> pitchMin,
|
||||
NumberValue<Float> pitchMax,
|
||||
NumberValue<Float> snapThreshold,
|
||||
NumberValue<Float> jitterYaw,
|
||||
NumberValue<Float> jitterPitch,
|
||||
BooleanValue decelerateEnabled,
|
||||
NumberValue<Float> decelerateAngle,
|
||||
NumberValue<Float> decelerateMinFactor) {
|
||||
super("Smart");
|
||||
this.yawMin = yawMin;
|
||||
this.yawMax = yawMax;
|
||||
this.pitchMin = pitchMin;
|
||||
this.pitchMax = pitchMax;
|
||||
this.snapThreshold = snapThreshold;
|
||||
this.jitterYaw = jitterYaw;
|
||||
this.jitterPitch = jitterPitch;
|
||||
this.decelerateEnabled = decelerateEnabled;
|
||||
this.decelerateAngle = decelerateAngle;
|
||||
this.decelerateMinFactor = decelerateMinFactor;
|
||||
}
|
||||
|
||||
private static float smoothStep(float t) {
|
||||
return t * t * (3.0f - 2.0f * t);
|
||||
}
|
||||
|
||||
private static float randomInRange(float a, float b) {
|
||||
float min = Math.min(a, b);
|
||||
float max = Math.max(a, b);
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return (float) ThreadLocalRandom.current().nextDouble(min, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
boolean resetting = rotationTarget == null || rotationTarget.entity == null;
|
||||
int entityId = rotationTarget != null && rotationTarget.entity != null
|
||||
? rotationTarget.entity.getId()
|
||||
: Integer.MIN_VALUE;
|
||||
if (entityId != trackedEntityId || virtualRotation == null || resetting != wasResetting) {
|
||||
trackedEntityId = entityId;
|
||||
virtualRotation = currentRotation;
|
||||
resetTicks = 0;
|
||||
}
|
||||
wasResetting = resetting;
|
||||
|
||||
Rotation baseRotation = virtualRotation != null ? virtualRotation : currentRotation;
|
||||
float angle = baseRotation.angleTo(targetRotation);
|
||||
float finishThreshold = resetting
|
||||
? Mth.clamp(snapThreshold.get() * 0.28f, 0.12f, 0.45f)
|
||||
: Math.max(0.05f, snapThreshold.get());
|
||||
if (angle <= finishThreshold) {
|
||||
virtualRotation = targetRotation;
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
float yawFactor = randomInRange(yawMin.get(), yawMax.get());
|
||||
float pitchFactor = randomInRange(pitchMin.get(), pitchMax.get());
|
||||
|
||||
float distanceScale = 1.0f;
|
||||
if (rotationTarget != null && rotationTarget.entity != null) {
|
||||
double distance = RotationManager.boxedDistanceToPlayer(rotationTarget.entity);
|
||||
distanceScale = Mth.clamp((float) (0.80 + distance * 0.15), 0.80f, 1.55f);
|
||||
}
|
||||
|
||||
float motionScale = resetting ? computeResetScale(angle) : computeDecelerateScale(angle);
|
||||
Rotation stepped = baseRotation.towardsLinear(
|
||||
targetRotation,
|
||||
yawFactor * distanceScale * motionScale,
|
||||
pitchFactor * distanceScale * motionScale
|
||||
);
|
||||
|
||||
float jitterScale = Mth.clamp(angle / 30.0f, 0.0f, 1.0f);
|
||||
if (resetting) {
|
||||
jitterScale *= Mth.clamp(angle / 18.0f, 0.0f, 0.35f);
|
||||
resetTicks++;
|
||||
}
|
||||
if (jitterScale > 0.0f) {
|
||||
float phase = (float) (System.currentTimeMillis() * 0.015);
|
||||
int jitterEntityId = rotationTarget != null && rotationTarget.entity != null ? rotationTarget.entity.getId() : 0;
|
||||
float seed = jitterEntityId * 0.137f;
|
||||
float yawOffset = (float) Math.sin(phase + seed) * jitterYaw.get() * jitterScale;
|
||||
float pitchOffset = (float) Math.cos(phase * 1.11f + seed) * jitterPitch.get() * jitterScale;
|
||||
Rotation output = new Rotation(
|
||||
stepped.yaw() + yawOffset,
|
||||
Mth.clamp(stepped.pitch() + pitchOffset, -90.0f, 90.0f),
|
||||
false
|
||||
);
|
||||
virtualRotation = output;
|
||||
return output;
|
||||
}
|
||||
|
||||
virtualRotation = stepped;
|
||||
return stepped;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
|
||||
Rotation rot = currentRotation;
|
||||
int ticks = 0;
|
||||
while (!rot.approximatelyEquals(targetRotation) && ticks < 80) {
|
||||
float yawFactor = (Math.min(yawMin.get(), yawMax.get()) + Math.max(yawMin.get(), yawMax.get())) * 0.5f;
|
||||
float pitchFactor = (Math.min(pitchMin.get(), pitchMax.get()) + Math.max(pitchMin.get(), pitchMax.get())) * 0.5f;
|
||||
float angle = rot.angleTo(targetRotation);
|
||||
float decelerateScale = computeDecelerateScale(angle);
|
||||
rot = rot.towardsLinear(targetRotation, yawFactor * decelerateScale, pitchFactor * decelerateScale);
|
||||
ticks++;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
private float computeResetScale(float angle) {
|
||||
float ramp = Mth.clamp((resetTicks + 1.0f) / 4.0f, 0.0f, 1.0f);
|
||||
float accel = 0.38f + 0.62f * smoothStep(ramp);
|
||||
return Mth.clamp(computeDecelerateScale(angle) * accel, 0.08f, 1.0f);
|
||||
}
|
||||
|
||||
private float computeDecelerateScale(float angle) {
|
||||
if (!decelerateEnabled.get()) {
|
||||
return 1.0f;
|
||||
}
|
||||
|
||||
float start = Math.max(0.05f, decelerateAngle.get());
|
||||
float min = Mth.clamp(decelerateMinFactor.get(), 0.05f, 1.0f);
|
||||
float t = Mth.clamp(angle / start, 0.0f, 1.0f);
|
||||
float smooth = smoothStep(t);
|
||||
return min + (1.0f - min) * smooth;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.anglesmooth.impl.curves;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.anglesmooth.AngleSmooth;
|
||||
import dev.loki.lovisual.util.click.AttackPressing;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class SpookyAngleSmooth extends AngleSmooth {
|
||||
|
||||
private float currentJitterYaw = 0.0f;
|
||||
private float currentJitterPitch = 0.0f;
|
||||
private float targetJitterYaw = 0.0f;
|
||||
private float targetJitterPitch = 0.0f;
|
||||
|
||||
private float circlePhase = 0.0f;
|
||||
private float circleRadius = 0.0f;
|
||||
private float targetCircleRadius = 0.0f;
|
||||
private float currentSpeed = 0.0f;
|
||||
private int trackedEntityId = Integer.MIN_VALUE;
|
||||
private Rotation virtualRotation;
|
||||
|
||||
public SpookyAngleSmooth() {
|
||||
super("Spooky");
|
||||
}
|
||||
|
||||
private static boolean isAttackWindow() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
return mc.player != null && mc.player.getAttackStrengthScale(0.0f) >= 0.95f;
|
||||
}
|
||||
|
||||
private static Vec3 hitbox(Entity entity, float xDiv, float yDiv, float zDiv, float widthDiv) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) {
|
||||
return entity.getBoundingBox().getCenter();
|
||||
}
|
||||
|
||||
double wHalf = entity.getBbWidth() / Math.max(1.0f, widthDiv);
|
||||
double yExpand = Mth.clamp(entity.getEyeY() - entity.getY(), 0.0, entity.getBbHeight());
|
||||
double xExpand = Mth.clamp(mc.player.getX() - entity.getX(), -wHalf, wHalf);
|
||||
double zExpand = Mth.clamp(mc.player.getZ() - entity.getZ(), -wHalf, wHalf);
|
||||
|
||||
return new Vec3(
|
||||
entity.getX() + xExpand / Math.max(0.001f, xDiv),
|
||||
entity.getY() + yExpand / Math.max(0.001f, yDiv),
|
||||
entity.getZ() + zExpand / Math.max(0.001f, zDiv)
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean isLookingAtHitbox(Entity entity, Rotation currentRotation) {
|
||||
if (entity == null) {
|
||||
return false;
|
||||
}
|
||||
return RaycastUtil.isLookingAtEntity(
|
||||
RotationManager.player(),
|
||||
entity,
|
||||
currentRotation.yaw(),
|
||||
currentRotation.pitch(),
|
||||
4.0,
|
||||
0.0
|
||||
) != null;
|
||||
}
|
||||
|
||||
private static float random(float a, float b) {
|
||||
float min = Math.min(a, b);
|
||||
float max = Math.max(a, b);
|
||||
if (max <= min) {
|
||||
return min;
|
||||
}
|
||||
return (float) ThreadLocalRandom.current().nextDouble(min, max);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
Entity entity = rotationTarget != null ? rotationTarget.entity : null;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
int entityId = entity != null ? entity.getId() : Integer.MIN_VALUE;
|
||||
if (entityId != trackedEntityId || virtualRotation == null) {
|
||||
trackedEntityId = entityId;
|
||||
virtualRotation = currentRotation;
|
||||
}
|
||||
|
||||
Rotation baseRotation = virtualRotation != null ? virtualRotation : currentRotation;
|
||||
boolean canAttack = entity != null && isAttackWindow();
|
||||
boolean lookingAtHitbox = !canAttack && isLookingAtHitbox(entity, baseRotation);
|
||||
|
||||
if (mc.player != null && entity != null && canAttack) {
|
||||
Vec3 aimPoint = hitbox(entity, 1.0f, entity.onGround() ? 1.0f : 1.256f, 1.0f, 2.0f);
|
||||
targetRotation = Rotation.lookingAt(aimPoint, mc.player.getEyePosition());
|
||||
}
|
||||
|
||||
float yawDelta = Mth.wrapDegrees(targetRotation.yaw() - baseRotation.yaw());
|
||||
float pitchDelta = Mth.wrapDegrees(targetRotation.pitch() - baseRotation.pitch());
|
||||
float rotationDifference = (float) Math.hypot(Math.abs(yawDelta), Math.abs(pitchDelta));
|
||||
if (rotationDifference < 0.01f) {
|
||||
rotationDifference = 1.0f;
|
||||
}
|
||||
|
||||
circlePhase += 0.75f * random(7.5f, 12.5f);
|
||||
if (circlePhase > Math.PI * 2) {
|
||||
circlePhase -= (float) (Math.PI * 2);
|
||||
}
|
||||
|
||||
if (canAttack) {
|
||||
targetCircleRadius = random(0.5f, 4.5f);
|
||||
} else if (lookingAtHitbox) {
|
||||
targetCircleRadius = 12.0f;
|
||||
} else {
|
||||
targetCircleRadius = random(8.0f, 12.0f);
|
||||
}
|
||||
circleRadius += (targetCircleRadius - circleRadius) * 0.18f;
|
||||
|
||||
float circleYaw = (float) (Math.cos(circlePhase) * circleRadius);
|
||||
float circlePitch = (float) (Math.sin(circlePhase * 11.3f) * circleRadius * 0.4f);
|
||||
|
||||
long elapsedMs = AttackPressing.INSTANCE.lastClickPassed();
|
||||
int count = AttackPressing.INSTANCE.clickCount();
|
||||
float timeRandom = elapsedMs / 100.0f + (count % 5);
|
||||
int pattern = count % 4;
|
||||
|
||||
float sinA = (float) Math.sin(timeRandom);
|
||||
float cosA = (float) Math.cos(timeRandom);
|
||||
float sinB = (float) Math.sin(timeRandom * 2.2f);
|
||||
float cosB = (float) Math.cos(timeRandom * 0.6f);
|
||||
float sinC = (float) Math.sin(timeRandom * 2.1f);
|
||||
float cosC = (float) Math.cos(timeRandom * 0.5f);
|
||||
|
||||
float patternYaw = switch (pattern) {
|
||||
case 0 -> cosA;
|
||||
case 1 -> sinB;
|
||||
case 2 -> sinA;
|
||||
default -> -cosC;
|
||||
};
|
||||
float patternPitch = switch (pattern) {
|
||||
case 0 -> sinA;
|
||||
case 1 -> cosB;
|
||||
case 2 -> -cosA;
|
||||
default -> sinC;
|
||||
};
|
||||
|
||||
float jitterMultiplier = canAttack ? 0.5f : (lookingAtHitbox ? 0.6f : 1.0f);
|
||||
targetJitterYaw = random(32.0f, 35.0f) * patternYaw * jitterMultiplier;
|
||||
targetJitterPitch = random(2.0f, 5.0f) * patternPitch * jitterMultiplier;
|
||||
|
||||
currentJitterYaw += (targetJitterYaw - currentJitterYaw) * 0.15f;
|
||||
currentJitterPitch += (targetJitterPitch - currentJitterPitch) * 0.15f;
|
||||
|
||||
float targetSpeed;
|
||||
if (canAttack) {
|
||||
targetSpeed = 1.0f;
|
||||
} else if (lookingAtHitbox) {
|
||||
targetSpeed = random(0.15f, 0.35f);
|
||||
} else if (entity != null) {
|
||||
float distanceFactor = Mth.clamp(rotationDifference / 30.0f, 0.1f, 1.0f);
|
||||
targetSpeed = random(0.25f, 0.45f) * distanceFactor;
|
||||
} else {
|
||||
targetSpeed = AttackPressing.INSTANCE.lastClickPassed() > 600L ? random(0.2f, 0.35f) : 0.53f;
|
||||
}
|
||||
currentSpeed += (targetSpeed - currentSpeed) * 0.65f;
|
||||
|
||||
float lineYaw = Math.abs(yawDelta / rotationDifference) * 180.0f;
|
||||
float linePitch = Math.abs(pitchDelta / rotationDifference) * 90.0f;
|
||||
float moveYaw = Mth.clamp(yawDelta, -lineYaw, lineYaw);
|
||||
float movePitch = Mth.clamp(pitchDelta, -linePitch, linePitch);
|
||||
|
||||
float totalJitterYaw = currentJitterYaw + circleYaw;
|
||||
float totalJitterPitch = currentJitterPitch + circlePitch;
|
||||
|
||||
if (entity == null && AttackPressing.INSTANCE.lastClickPassed() > 800L) {
|
||||
totalJitterYaw *= 0.3f;
|
||||
totalJitterPitch *= 0.3f;
|
||||
}
|
||||
|
||||
float newYaw = Mth.lerp(currentSpeed, baseRotation.yaw(), baseRotation.yaw() + moveYaw) + totalJitterYaw;
|
||||
float newPitch = Mth.lerp(currentSpeed, baseRotation.pitch(), baseRotation.pitch() + movePitch) + totalJitterPitch;
|
||||
Rotation output = new Rotation(newYaw, Mth.clamp(newPitch, -90.0f, 90.0f), false);
|
||||
virtualRotation = output;
|
||||
return output;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int calculateTicks(Rotation currentRotation, Rotation targetRotation) {
|
||||
Rotation rot = currentRotation;
|
||||
int ticks = 0;
|
||||
while (!rot.approximatelyEquals(targetRotation) && ticks < 80) {
|
||||
rot = rot.towardsLinear(targetRotation, 90.0f, 65.0f);
|
||||
ticks++;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.impl;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
public final class LazyAimProcessor implements RotationProcessor {
|
||||
|
||||
private final NumberValue<Float> threshold;
|
||||
private final NumberValue<Float> followFactor;
|
||||
|
||||
public LazyAimProcessor(NumberValue<Float> threshold, NumberValue<Float> followFactor) {
|
||||
this.threshold = threshold;
|
||||
this.followFactor = followFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
float angle = currentRotation.angleTo(targetRotation);
|
||||
if (angle <= threshold.get()) {
|
||||
return currentRotation;
|
||||
}
|
||||
|
||||
float factor = Math.max(0.1f, followFactor.get()) * Math.max(angle, 1.0f);
|
||||
return currentRotation.towardsLinear(targetRotation, factor, factor);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.impl;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
/**
|
||||
* Adds conservative per-tick rotation limits while moving to avoid sharp
|
||||
* silent-yaw snaps that anti-cheats can flag more easily than pure input mismatch.
|
||||
*/
|
||||
public final class MovingRotationProcessor implements RotationProcessor {
|
||||
|
||||
private static final double MIN_HORIZONTAL_SPEED = 0.045;
|
||||
private static final float MAX_MOVING_YAW_STEP = 7.5f;
|
||||
private static final float MAX_MOVING_PITCH_STEP = 10.0f;
|
||||
private static final float MAX_MOVEMENT_YAW_OFFSET = 82.0f;
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc == null ? null : mc.player;
|
||||
if (player == null || currentRotation == null || targetRotation == null) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
Vec3 velocity = player.getDeltaMovement();
|
||||
double horizontalSpeedSq = velocity.x * velocity.x + velocity.z * velocity.z;
|
||||
if (horizontalSpeedSq < MIN_HORIZONTAL_SPEED * MIN_HORIZONTAL_SPEED) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
float movementYaw = (float) Math.toDegrees(Math.atan2(velocity.z, velocity.x)) - 90.0f;
|
||||
movementYaw = Mth.wrapDegrees(movementYaw);
|
||||
|
||||
float movementDelta = RotationUtil.angleDifference(targetRotation.yaw(), movementYaw);
|
||||
float clampedYaw = movementYaw + Mth.clamp(movementDelta, -MAX_MOVEMENT_YAW_OFFSET, MAX_MOVEMENT_YAW_OFFSET);
|
||||
|
||||
Rotation softenedTarget = new Rotation(clampedYaw, targetRotation.pitch(), false);
|
||||
return currentRotation.towardsLinear(softenedTarget, MAX_MOVING_YAW_STEP, MAX_MOVING_PITCH_STEP);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.impl;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
public final class ShakeAimProcessor implements RotationProcessor {
|
||||
|
||||
private final NumberValue<Float> yawAmplitude;
|
||||
private final NumberValue<Float> pitchAmplitude;
|
||||
private final NumberValue<Float> speed;
|
||||
|
||||
public ShakeAimProcessor(NumberValue<Float> yawAmplitude,
|
||||
NumberValue<Float> pitchAmplitude,
|
||||
NumberValue<Float> speed) {
|
||||
this.yawAmplitude = yawAmplitude;
|
||||
this.pitchAmplitude = pitchAmplitude;
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
float phase = mc.player.tickCount * Math.max(0.01f, speed.get());
|
||||
int entityId = rotationTarget != null && rotationTarget.entity != null ? rotationTarget.entity.getId() : 0;
|
||||
float seed = entityId * 0.173f;
|
||||
|
||||
float yawOffset = (float) Math.sin(phase + seed) * yawAmplitude.get();
|
||||
float pitchOffset = (float) Math.cos(phase * 1.17f + seed) * pitchAmplitude.get();
|
||||
return new Rotation(targetRotation.yaw() + yawOffset, targetRotation.pitch() + pitchOffset);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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.aiming.features.processors.impl;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationTarget;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
public final class SnapAssistAimProcessor implements RotationProcessor {
|
||||
|
||||
private final NumberValue<Float> snapThreshold;
|
||||
private final NumberValue<Float> snapFactor;
|
||||
|
||||
public SnapAssistAimProcessor(NumberValue<Float> snapThreshold, NumberValue<Float> snapFactor) {
|
||||
this.snapThreshold = snapThreshold;
|
||||
this.snapFactor = snapFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Rotation process(RotationTarget rotationTarget, Rotation currentRotation, Rotation targetRotation) {
|
||||
float angle = currentRotation.angleTo(targetRotation);
|
||||
if (angle <= snapThreshold.get()) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
float rampWindow = Math.max(snapThreshold.get() * 2.0f, snapThreshold.get() + 1.0f);
|
||||
if (angle > rampWindow) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
float factor = Math.max(1.0f, snapFactor.get()) * angle;
|
||||
return currentRotation.towardsLinear(targetRotation, factor, factor);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.aiming.point;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public record PointInsideBox(Vec3 pos, AABB box) {
|
||||
|
||||
public PointInsideBox {
|
||||
pos = clampToBox(pos, box);
|
||||
}
|
||||
|
||||
private static Vec3 clampToBox(Vec3 pos, AABB box) {
|
||||
return new Vec3(
|
||||
Mth.clamp(pos.x, box.minX, box.maxX),
|
||||
Mth.clamp(pos.y, box.minY, box.maxY),
|
||||
Mth.clamp(pos.z, box.minZ, box.maxZ)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
/*
|
||||
* 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.aiming.point;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.entity.simulation.PositionExtrapolation;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class PointTracker {
|
||||
|
||||
private int trackedEntityId = -1;
|
||||
private PointInsideBox delayedPoint;
|
||||
private int currentDelay = randomInt(2, 4);
|
||||
private PointInsideBox lazyPoint;
|
||||
private double currentLazyThreshold;
|
||||
private Vec3 currentOffset = Vec3.ZERO;
|
||||
private Vec3 targetOffset = Vec3.ZERO;
|
||||
private Vec3 smoothedPoint;
|
||||
private Vec3 lastSelectedPoint;
|
||||
private Vec3 lastEyes;
|
||||
private Vec3 lastAimDirection;
|
||||
private Vec3 instabilityOffset = Vec3.ZERO;
|
||||
private Vec3 targetInstabilityOffset = Vec3.ZERO;
|
||||
private int instabilityRetargetTicks;
|
||||
|
||||
private boolean delayEnabled = true;
|
||||
private int delayMin = 2;
|
||||
private int delayMax = 4;
|
||||
private boolean lazyEnabled = true;
|
||||
private double lazyMin = 0.10;
|
||||
private double lazyMax = 0.20;
|
||||
private boolean gaussianEnabled;
|
||||
private double gaussianYawFactor;
|
||||
private double gaussianPitchFactor;
|
||||
private int gaussianChance = 100;
|
||||
private double gaussianSpeed = 0.15;
|
||||
|
||||
private static float scoreCandidate(Vec3 eyes,
|
||||
Rotation initialRotation,
|
||||
Rotation candidateRotation,
|
||||
Vec3 point,
|
||||
Vec3 center,
|
||||
AABB box,
|
||||
Vec3 aimDir,
|
||||
Vec3 movement,
|
||||
double movementStrength) {
|
||||
float rotationScore = initialRotation.angleTo(candidateRotation) * 1.15f;
|
||||
double halfX = Math.max(1.0E-4, box.getXsize() * 0.5);
|
||||
double halfY = Math.max(1.0E-4, box.getYsize() * 0.5);
|
||||
double halfZ = Math.max(1.0E-4, box.getZsize() * 0.5);
|
||||
|
||||
double nx = Math.abs(point.x - center.x) / halfX;
|
||||
double ny = Math.abs(point.y - center.y) / halfY;
|
||||
double nz = Math.abs(point.z - center.z) / halfZ;
|
||||
double edgeFactor = Math.max(Math.max(nx, nz), ny * 0.65);
|
||||
double centerPenalty = (1.0 - Mth.clamp(edgeFactor, 0.0, 1.0))
|
||||
* (movementStrength > 0.035 ? 6.0 : 4.0);
|
||||
|
||||
double rayDistance = distanceToRay(eyes, aimDir, point);
|
||||
double rayScale = Math.max(0.08, Math.max(box.getXsize(), Math.max(box.getYsize(), box.getZsize())) * 0.5);
|
||||
double rayScore = (rayDistance / rayScale) * (movementStrength > 0.035 ? 5.5 : 4.0);
|
||||
|
||||
double movementBenefit = movingPointBenefit(point, center, box, movement, movementStrength);
|
||||
return rotationScore + (float) rayScore + (float) centerPenalty - (float) movementBenefit;
|
||||
}
|
||||
|
||||
private static List<Candidate> filterDiverse(List<Candidate> candidates, Vec3 reference, AABB box, boolean movingTarget) {
|
||||
List<Candidate> out = new ArrayList<>();
|
||||
if (reference == null) {
|
||||
return out;
|
||||
}
|
||||
|
||||
double minAxis = Math.min(box.getXsize(), Math.min(box.getYsize(), box.getZsize()));
|
||||
double minDistance = Math.max(movingTarget ? 0.085 : 0.055, minAxis * (movingTarget ? 0.26 : 0.18));
|
||||
double minDistanceSq = minDistance * minDistance;
|
||||
for (Candidate candidate : candidates) {
|
||||
if (candidate.point().distanceToSqr(reference) >= minDistanceSq) {
|
||||
out.add(candidate);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Candidate selectWeighted(List<Candidate> candidates, float bestScore) {
|
||||
if (candidates.size() == 1) {
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
double total = 0.0;
|
||||
double[] weights = new double[candidates.size()];
|
||||
for (int i = 0; i < candidates.size(); i++) {
|
||||
double delta = Math.max(0.0, candidates.get(i).score() - bestScore);
|
||||
double weight = 1.0 / (1.0 + delta * delta * 0.08);
|
||||
weights[i] = weight;
|
||||
total += weight;
|
||||
}
|
||||
|
||||
double pick = ThreadLocalRandom.current().nextDouble(total);
|
||||
for (int i = 0; i < candidates.size(); i++) {
|
||||
pick -= weights[i];
|
||||
if (pick <= 0.0) {
|
||||
return candidates.get(i);
|
||||
}
|
||||
}
|
||||
return candidates.get(candidates.size() - 1);
|
||||
}
|
||||
|
||||
private static double movingPointBenefit(Vec3 point, Vec3 center, AABB box, Vec3 movement, double movementStrength) {
|
||||
if (movementStrength <= 0.035) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double halfX = Math.max(1.0E-4, box.getXsize() * 0.5);
|
||||
double halfZ = Math.max(1.0E-4, box.getZsize() * 0.5);
|
||||
double dirX = movement.x / movementStrength;
|
||||
double dirZ = movement.z / movementStrength;
|
||||
double offX = Mth.clamp((point.x - center.x) / halfX, -1.0, 1.0);
|
||||
double offZ = Mth.clamp((point.z - center.z) / halfZ, -1.0, 1.0);
|
||||
double lead = Mth.clamp(offX * dirX + offZ * dirZ, -1.0, 1.0);
|
||||
double sideStrength = Math.min(1.0, Math.hypot(offX, offZ));
|
||||
|
||||
return Math.max(0.0, lead) * 4.0 + sideStrength * 1.4;
|
||||
}
|
||||
|
||||
private static double distanceToRay(Vec3 origin, Vec3 direction, Vec3 point) {
|
||||
Vec3 diff = point.subtract(origin);
|
||||
double t = Math.max(0.0, diff.dot(direction));
|
||||
Vec3 closest = origin.add(direction.scale(t));
|
||||
return point.distanceTo(closest);
|
||||
}
|
||||
|
||||
private static Vec3 findRayHitPoint(Vec3 eyes, Vec3 aimDir, AABB box, double maxDistance) {
|
||||
double range = maxDistance > 0.0 && maxDistance < Double.MAX_VALUE ? maxDistance : 6.0;
|
||||
return box.clip(eyes, eyes.add(aimDir.scale(range))).orElse(null);
|
||||
}
|
||||
|
||||
private static double horizontalLength(Vec3 vec) {
|
||||
if (vec == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return Math.hypot(vec.x, vec.z);
|
||||
}
|
||||
|
||||
private static boolean isValidPoint(Vec3 startPoint,
|
||||
Vec3 endPoint,
|
||||
double maxDistance,
|
||||
boolean ignoreWalls) {
|
||||
if (startPoint == null || endPoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxDistance > 0.0 && startPoint.distanceToSqr(endPoint) > maxDistance * maxDistance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ignoreWalls || RaycastUtil.hasLineOfSightPoint(startPoint, endPoint);
|
||||
}
|
||||
|
||||
private static Vec3 randomInstabilityOffset(AABB box, Vec3 entityVelocity, Vec3 aimDirection, double intensity) {
|
||||
double halfX = Math.max(0.001, box.getXsize() * 0.5);
|
||||
double halfY = Math.max(0.001, box.getYsize() * 0.5);
|
||||
double halfZ = Math.max(0.001, box.getZsize() * 0.5);
|
||||
|
||||
double speed = horizontalLength(entityVelocity);
|
||||
double sideX;
|
||||
double sideZ;
|
||||
if (speed > 0.035) {
|
||||
sideX = entityVelocity.x / speed;
|
||||
sideZ = entityVelocity.z / speed;
|
||||
} else {
|
||||
Vec3 horizontalAim = new Vec3(aimDirection.x, 0.0, aimDirection.z);
|
||||
double aimLen = Math.max(1.0E-4, horizontalLength(horizontalAim));
|
||||
sideX = -horizontalAim.z / aimLen;
|
||||
sideZ = horizontalAim.x / aimLen;
|
||||
}
|
||||
|
||||
double lateral = randomDouble(-1.0, 1.0) * (0.14 + 0.22 * intensity);
|
||||
double lead = speed > 0.035 ? randomDouble(0.05, 0.24) * intensity : 0.0;
|
||||
double vertical = randomDouble(-0.16, 0.14) * intensity;
|
||||
|
||||
return new Vec3(
|
||||
sideX * halfX * lateral + sideX * halfX * lead,
|
||||
halfY * vertical,
|
||||
sideZ * halfZ * lateral + sideZ * halfZ * lead
|
||||
);
|
||||
}
|
||||
|
||||
private static Vec3 clampToBox(Vec3 point, AABB box) {
|
||||
return new Vec3(
|
||||
Mth.clamp(point.x, box.minX, box.maxX),
|
||||
Mth.clamp(point.y, box.minY, box.maxY),
|
||||
Mth.clamp(point.z, box.minZ, box.maxZ)
|
||||
);
|
||||
}
|
||||
|
||||
private static Vec3 getNearestPoint(AABB box, Vec3 eyes) {
|
||||
return new Vec3(
|
||||
Mth.clamp(eyes.x, box.minX, box.maxX),
|
||||
Mth.clamp(eyes.y, box.minY, box.maxY),
|
||||
Mth.clamp(eyes.z, box.minZ, box.maxZ)
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Vec3> projectPointsOnBox(Vec3 eyes, AABB box) {
|
||||
List<Vec3> points = new ArrayList<>();
|
||||
double[] steps = proportions();
|
||||
|
||||
if (!box.contains(eyes)) {
|
||||
for (double y : steps) {
|
||||
for (double z : steps) {
|
||||
points.add(new Vec3(box.minX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
|
||||
points.add(new Vec3(box.maxX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
|
||||
}
|
||||
}
|
||||
for (double x : steps) {
|
||||
for (double z : steps) {
|
||||
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY, box.minZ + box.getZsize() * z));
|
||||
points.add(new Vec3(box.minX + box.getXsize() * x, box.maxY, box.minZ + box.getZsize() * z));
|
||||
}
|
||||
}
|
||||
for (double x : steps) {
|
||||
for (double y : steps) {
|
||||
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.minZ));
|
||||
points.add(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.maxZ));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
for (double x : steps) {
|
||||
for (double y : steps) {
|
||||
for (double z : steps) {
|
||||
points.add(new Vec3(
|
||||
box.minX + box.getXsize() * x,
|
||||
box.minY + box.getYsize() * y,
|
||||
box.minZ + box.getZsize() * z
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static int randomInt(int min, int max) {
|
||||
if (max <= min) return min;
|
||||
return ThreadLocalRandom.current().nextInt(min, max + 1);
|
||||
}
|
||||
|
||||
private static double randomDouble(double min, double max) {
|
||||
if (max <= min) return min;
|
||||
return ThreadLocalRandom.current().nextDouble(min, max);
|
||||
}
|
||||
|
||||
private static double[] proportions() {
|
||||
double[] steps = new double[13];
|
||||
for (int i = 0; i < steps.length; i++) {
|
||||
steps[i] = (i + 0.5) / steps.length;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
public void setOptions(boolean delayEnabled,
|
||||
int delayMin,
|
||||
int delayMax,
|
||||
boolean lazyEnabled,
|
||||
double lazyMin,
|
||||
double lazyMax,
|
||||
boolean gaussianEnabled,
|
||||
double gaussianYawFactor,
|
||||
double gaussianPitchFactor,
|
||||
int gaussianChance,
|
||||
double gaussianSpeed) {
|
||||
this.delayEnabled = delayEnabled;
|
||||
this.delayMin = Math.max(0, Math.min(delayMin, delayMax));
|
||||
this.delayMax = Math.max(this.delayMin, delayMax);
|
||||
this.lazyEnabled = lazyEnabled;
|
||||
this.lazyMin = Math.max(0.0, Math.min(lazyMin, lazyMax));
|
||||
this.lazyMax = Math.max(this.lazyMin, lazyMax);
|
||||
this.gaussianEnabled = gaussianEnabled;
|
||||
this.gaussianYawFactor = Math.max(0.0, gaussianYawFactor);
|
||||
this.gaussianPitchFactor = Math.max(0.0, gaussianPitchFactor);
|
||||
this.gaussianChance = Math.max(0, Math.min(100, gaussianChance));
|
||||
this.gaussianSpeed = Math.max(0.01, Math.min(1.0, gaussianSpeed));
|
||||
}
|
||||
|
||||
public PointInsideBox findPoint(Vec3 eyes,
|
||||
Entity entity,
|
||||
int ticks,
|
||||
Rotation initialRotation,
|
||||
double maxDistance,
|
||||
boolean ignoreWalls) {
|
||||
AABB box = PositionExtrapolation.getBestForEntity(entity).getBoxInTicks(ticks);
|
||||
List<Vec3> points = projectPointsOnBox(eyes, box);
|
||||
|
||||
if (trackedEntityId != entity.getId()) {
|
||||
resetProcessors();
|
||||
trackedEntityId = entity.getId();
|
||||
}
|
||||
|
||||
Vec3 bestPoint = selectMultiPoint(eyes, entity, box, initialRotation, maxDistance, ignoreWalls);
|
||||
if (bestPoint == null) {
|
||||
Vec3 anchor = smoothedPoint != null ? clampToBox(smoothedPoint, box) : eyes;
|
||||
bestPoint = points.isEmpty() ? getNearestPoint(box, anchor) : points.get(0);
|
||||
for (Vec3 point : points) {
|
||||
if (point.distanceToSqr(anchor) < bestPoint.distanceToSqr(anchor)) {
|
||||
bestPoint = point;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PointInsideBox point = new PointInsideBox(clampToBox(bestPoint, box), box);
|
||||
point = applyDelay(point);
|
||||
point = applyLazy(point);
|
||||
point = applyGaussian(point);
|
||||
point = applyMotionInstability(point, eyes, entity, initialRotation);
|
||||
point = applyContinuity(point);
|
||||
smoothedPoint = point.pos();
|
||||
return new PointInsideBox(clampToBox(point.pos(), box), box);
|
||||
}
|
||||
|
||||
public PointInsideBox findPoint(Vec3 eyes, Entity entity, int ticks, Rotation initialRotation) {
|
||||
return findPoint(eyes, entity, ticks, initialRotation, Double.MAX_VALUE, true);
|
||||
}
|
||||
|
||||
private Vec3 selectMultiPoint(Vec3 eyes,
|
||||
Entity entity,
|
||||
AABB box,
|
||||
Rotation initialRotation,
|
||||
double maxDistance,
|
||||
boolean ignoreWalls) {
|
||||
if (eyes == null || box == null || initialRotation == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
float bestScore = Float.MAX_VALUE;
|
||||
Vec3 center = box.getCenter();
|
||||
Vec3 aimDir = initialRotation.directionVector().normalize();
|
||||
Vec3 movement = entity != null ? entity.getDeltaMovement() : Vec3.ZERO;
|
||||
double movementStrength = horizontalLength(movement);
|
||||
|
||||
Vec3 rayHit = findRayHitPoint(eyes, aimDir, box, maxDistance);
|
||||
if (rayHit != null && isValidPoint(eyes, rayHit, maxDistance, ignoreWalls)) {
|
||||
Rotation rayRotation = Rotation.lookingAt(rayHit, eyes).normalize();
|
||||
float score = scoreCandidate(eyes, initialRotation, rayRotation, rayHit, center, box,
|
||||
aimDir, movement, movementStrength) - 2.0f;
|
||||
candidates.add(new Candidate(rayHit, score));
|
||||
bestScore = score;
|
||||
}
|
||||
|
||||
for (Vec3 point : projectPointsOnBox(eyes, box)) {
|
||||
if (!isValidPoint(eyes, point, maxDistance, ignoreWalls)) {
|
||||
continue;
|
||||
}
|
||||
Rotation candidateRotation = Rotation.lookingAt(point, eyes).normalize();
|
||||
float score = scoreCandidate(eyes, initialRotation, candidateRotation, point, center, box,
|
||||
aimDir, movement, movementStrength);
|
||||
candidates.add(new Candidate(point, score));
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean movingTarget = movementStrength > 0.035 || Math.abs(movement.y) > 0.03;
|
||||
float threshold = movingTarget
|
||||
? Math.max(7.0f, Math.min(28.0f, bestScore * 0.55f + 6.0f))
|
||||
: Math.max(4.0f, Math.min(18.0f, bestScore * 0.35f + 3.0f));
|
||||
List<Candidate> pool = new ArrayList<>();
|
||||
for (Candidate candidate : candidates) {
|
||||
if (candidate.score() <= bestScore + threshold) {
|
||||
pool.add(candidate);
|
||||
}
|
||||
}
|
||||
if (pool.isEmpty()) {
|
||||
pool = candidates;
|
||||
}
|
||||
|
||||
Vec3 reference = lastSelectedPoint != null ? lastSelectedPoint : smoothedPoint;
|
||||
List<Candidate> diversePool = filterDiverse(pool, reference, box, movingTarget);
|
||||
if (!diversePool.isEmpty()) {
|
||||
pool = diversePool;
|
||||
}
|
||||
|
||||
Candidate selected = selectWeighted(pool, bestScore);
|
||||
lastSelectedPoint = selected.point();
|
||||
return selected.point();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
trackedEntityId = -1;
|
||||
resetProcessors();
|
||||
}
|
||||
|
||||
private void resetProcessors() {
|
||||
delayedPoint = null;
|
||||
currentDelay = randomInt(delayMin, delayMax);
|
||||
lazyPoint = null;
|
||||
currentLazyThreshold = 0.0;
|
||||
currentOffset = Vec3.ZERO;
|
||||
targetOffset = Vec3.ZERO;
|
||||
smoothedPoint = null;
|
||||
lastSelectedPoint = null;
|
||||
lastEyes = null;
|
||||
lastAimDirection = null;
|
||||
instabilityOffset = Vec3.ZERO;
|
||||
targetInstabilityOffset = Vec3.ZERO;
|
||||
instabilityRetargetTicks = 0;
|
||||
}
|
||||
|
||||
private PointInsideBox applyDelay(PointInsideBox point) {
|
||||
if (!delayEnabled) {
|
||||
delayedPoint = point;
|
||||
currentDelay = 0;
|
||||
return point;
|
||||
}
|
||||
|
||||
if (delayedPoint != null) {
|
||||
delayedPoint = new PointInsideBox(clampToBox(delayedPoint.pos(), point.box()), point.box());
|
||||
}
|
||||
|
||||
if (point.equals(delayedPoint)) {
|
||||
return point;
|
||||
}
|
||||
|
||||
if (delayedPoint == null) {
|
||||
delayedPoint = point;
|
||||
currentDelay = randomInt(delayMin, delayMax);
|
||||
return point;
|
||||
}
|
||||
|
||||
PointInsideBox currentPoint = delayedPoint;
|
||||
currentDelay--;
|
||||
if (currentDelay > 0) {
|
||||
return currentPoint;
|
||||
}
|
||||
|
||||
delayedPoint = point;
|
||||
currentDelay = randomInt(delayMin, delayMax);
|
||||
return currentPoint;
|
||||
}
|
||||
|
||||
private PointInsideBox applyLazy(PointInsideBox point) {
|
||||
if (!lazyEnabled) {
|
||||
lazyPoint = point;
|
||||
currentLazyThreshold = 0.0;
|
||||
return point;
|
||||
}
|
||||
|
||||
if (lazyPoint != null) {
|
||||
lazyPoint = new PointInsideBox(clampToBox(lazyPoint.pos(), point.box()), point.box());
|
||||
}
|
||||
|
||||
if (lazyPoint == null) {
|
||||
lazyPoint = point;
|
||||
currentLazyThreshold = randomDouble(lazyMin, lazyMax);
|
||||
return point;
|
||||
}
|
||||
|
||||
PointInsideBox currentPoint = lazyPoint;
|
||||
double thresholdSq = currentLazyThreshold * currentLazyThreshold;
|
||||
if (point.pos().distanceToSqr(currentPoint.pos()) < thresholdSq) {
|
||||
return currentPoint;
|
||||
}
|
||||
|
||||
lazyPoint = point;
|
||||
currentLazyThreshold = randomDouble(lazyMin, lazyMax);
|
||||
return currentPoint;
|
||||
}
|
||||
|
||||
private PointInsideBox applyGaussian(PointInsideBox point) {
|
||||
if (!gaussianEnabled || (gaussianYawFactor <= 0.0 && gaussianPitchFactor <= 0.0)) {
|
||||
currentOffset = Vec3.ZERO;
|
||||
targetOffset = Vec3.ZERO;
|
||||
return point;
|
||||
}
|
||||
|
||||
if (hasReachedOffsetTarget()) {
|
||||
if (ThreadLocalRandom.current().nextInt(100) < gaussianChance) {
|
||||
targetOffset = new Vec3(
|
||||
ThreadLocalRandom.current().nextGaussian() * gaussianYawFactor,
|
||||
ThreadLocalRandom.current().nextGaussian() * gaussianPitchFactor,
|
||||
ThreadLocalRandom.current().nextGaussian() * gaussianYawFactor
|
||||
);
|
||||
} else {
|
||||
targetOffset = Vec3.ZERO;
|
||||
}
|
||||
} else {
|
||||
currentOffset = new Vec3(
|
||||
Mth.lerp(gaussianSpeed, currentOffset.x, targetOffset.x),
|
||||
Mth.lerp(gaussianSpeed, currentOffset.y, targetOffset.y),
|
||||
Mth.lerp(gaussianSpeed, currentOffset.z, targetOffset.z)
|
||||
);
|
||||
}
|
||||
|
||||
return new PointInsideBox(point.pos().add(currentOffset), point.box());
|
||||
}
|
||||
|
||||
private PointInsideBox applyMotionInstability(PointInsideBox point,
|
||||
Vec3 eyes,
|
||||
Entity entity,
|
||||
Rotation initialRotation) {
|
||||
Vec3 aimDirection = initialRotation.directionVector().normalize();
|
||||
double angleChange = lastAimDirection != null
|
||||
? Math.toDegrees(Math.acos(Mth.clamp(lastAimDirection.dot(aimDirection), -1.0, 1.0)))
|
||||
: 0.0;
|
||||
double playerMove = lastEyes != null ? eyes.distanceTo(lastEyes) : 0.0;
|
||||
Vec3 entityVelocity = entity != null ? entity.getDeltaMovement() : Vec3.ZERO;
|
||||
double entityMove = horizontalLength(entityVelocity) + Math.abs(entityVelocity.y) * 0.45;
|
||||
|
||||
lastEyes = eyes;
|
||||
lastAimDirection = aimDirection;
|
||||
|
||||
double intensity = Mth.clamp(angleChange / 14.0 + playerMove / 0.14 + entityMove / 0.11, 0.0, 1.0);
|
||||
if (intensity < 0.12) {
|
||||
instabilityOffset = instabilityOffset.scale(0.72);
|
||||
targetInstabilityOffset = targetInstabilityOffset.scale(0.72);
|
||||
return new PointInsideBox(clampToBox(point.pos().add(instabilityOffset), point.box()), point.box());
|
||||
}
|
||||
|
||||
instabilityRetargetTicks--;
|
||||
if (instabilityRetargetTicks <= 0 || instabilityOffset.distanceToSqr(targetInstabilityOffset) < 1.0E-4) {
|
||||
targetInstabilityOffset = randomInstabilityOffset(point.box(), entityVelocity, aimDirection, intensity);
|
||||
instabilityRetargetTicks = randomInt(2, intensity > 0.65 ? 4 : 6);
|
||||
}
|
||||
|
||||
double blend = 0.14 + 0.18 * intensity;
|
||||
instabilityOffset = new Vec3(
|
||||
Mth.lerp(blend, instabilityOffset.x, targetInstabilityOffset.x),
|
||||
Mth.lerp(blend, instabilityOffset.y, targetInstabilityOffset.y),
|
||||
Mth.lerp(blend, instabilityOffset.z, targetInstabilityOffset.z)
|
||||
);
|
||||
|
||||
return new PointInsideBox(clampToBox(point.pos().add(instabilityOffset), point.box()), point.box());
|
||||
}
|
||||
|
||||
private PointInsideBox applyContinuity(PointInsideBox point) {
|
||||
if (smoothedPoint == null) {
|
||||
return point;
|
||||
}
|
||||
|
||||
Vec3 current = clampToBox(smoothedPoint, point.box());
|
||||
Vec3 target = clampToBox(point.pos(), point.box());
|
||||
double distance = current.distanceTo(target);
|
||||
if (distance <= 1.0E-4) {
|
||||
return new PointInsideBox(current, point.box());
|
||||
}
|
||||
|
||||
double blend = distance > 0.35 ? 0.42 : distance > 0.15 ? 0.28 : 0.18;
|
||||
Vec3 blended = new Vec3(
|
||||
Mth.lerp(blend, current.x, target.x),
|
||||
Mth.lerp(blend, current.y, target.y),
|
||||
Mth.lerp(blend, current.z, target.z)
|
||||
);
|
||||
return new PointInsideBox(blended, point.box());
|
||||
}
|
||||
|
||||
private boolean hasReachedOffsetTarget() {
|
||||
return Math.abs(currentOffset.x - targetOffset.x) < 0.01
|
||||
&& Math.abs(currentOffset.y - targetOffset.y) < 0.01
|
||||
&& Math.abs(currentOffset.z - targetOffset.z) < 0.01;
|
||||
}
|
||||
|
||||
private record Candidate(Vec3 point, float score) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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.aiming.preference;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationUtil;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class LeastDifferencePreference implements RotationPreference {
|
||||
|
||||
public static final LeastDifferencePreference LEAST_DISTANCE_TO_CURRENT_ROTATION =
|
||||
new LeastDifferencePreference(LeastDifferencePreference::currentRotation);
|
||||
private final Supplier<Rotation> baseSupplier;
|
||||
private final Vec3 basePoint;
|
||||
|
||||
public LeastDifferencePreference(Rotation baseRotation) {
|
||||
this(() -> baseRotation, null);
|
||||
}
|
||||
|
||||
private LeastDifferencePreference(Supplier<Rotation> supplier) {
|
||||
this(supplier, null);
|
||||
}
|
||||
|
||||
private LeastDifferencePreference(Supplier<Rotation> supplier, Vec3 basePoint) {
|
||||
this.baseSupplier = Objects.requireNonNull(supplier, "supplier");
|
||||
this.basePoint = basePoint;
|
||||
}
|
||||
|
||||
public static RotationPreference leastDifferenceToLastPoint(Vec3 eyes, Vec3 lastPoint) {
|
||||
if (eyes == null || lastPoint == null) {
|
||||
return LEAST_DISTANCE_TO_CURRENT_ROTATION;
|
||||
}
|
||||
return new LeastDifferencePreference(() -> Rotation.lookingAt(lastPoint, eyes), lastPoint);
|
||||
}
|
||||
|
||||
private static Rotation currentRotation() {
|
||||
Rotation current = RotationManager.INSTANCE.getCurrentRotation();
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc != null && mc.player != null) {
|
||||
return new Rotation(mc.player.getYRot(), mc.player.getXRot(), false).normalize();
|
||||
}
|
||||
return Rotation.ZERO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(Rotation a, Rotation b) {
|
||||
Rotation base = baseSupplier.get();
|
||||
return Float.compare(base.angleTo(a), base.angleTo(b));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Vec3 getPreferredSpotOnBox(AABB box, Vec3 eyes, double range) {
|
||||
if (basePoint != null) {
|
||||
return basePoint;
|
||||
}
|
||||
|
||||
Rotation base = baseSupplier.get();
|
||||
Vec3 dir = RotationUtil.getRotationVector(base.pitch(), base.yaw()).normalize();
|
||||
return eyes.add(dir.scale(range));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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.aiming.preference;
|
||||
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
public interface RotationPreference extends Comparator<Rotation> {
|
||||
|
||||
Vec3 getPreferredSpotOnBox(AABB box, Vec3 eyes, double range);
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
/*
|
||||
* 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.aiming.raytrace;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.data.RotationWithVector;
|
||||
import dev.loki.lovisual.util.aiming.preference.LeastDifferencePreference;
|
||||
import dev.loki.lovisual.util.aiming.preference.RotationPreference;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public enum RotationRaytrace {
|
||||
;
|
||||
|
||||
public static final VisibilityPredicate OUTLINE_VISIBILITY =
|
||||
(eyes, target) -> RaycastUtil.hasLineOfSightPoint(eyes, target);
|
||||
|
||||
public static RotationWithVector raytraceBox(
|
||||
Vec3 eyes,
|
||||
AABB box,
|
||||
double range,
|
||||
double wallsRange
|
||||
) {
|
||||
return raytraceBox(eyes, box, range, wallsRange, OUTLINE_VISIBILITY,
|
||||
LeastDifferencePreference.LEAST_DISTANCE_TO_CURRENT_ROTATION, true);
|
||||
}
|
||||
|
||||
public static RotationWithVector raytraceBox(
|
||||
Vec3 eyes,
|
||||
AABB box,
|
||||
double range,
|
||||
double wallsRange,
|
||||
VisibilityPredicate visibilityPredicate,
|
||||
RotationPreference rotationPreference,
|
||||
boolean prioritizeVisible
|
||||
) {
|
||||
if (eyes == null || box == null) return null;
|
||||
final VisibilityPredicate vis =
|
||||
visibilityPredicate == null ? OUTLINE_VISIBILITY : visibilityPredicate;
|
||||
final RotationPreference pref =
|
||||
rotationPreference == null ? LeastDifferencePreference.LEAST_DISTANCE_TO_CURRENT_ROTATION : rotationPreference;
|
||||
|
||||
double rangeSq = range * range;
|
||||
double wallsRangeSq = wallsRange * wallsRange;
|
||||
|
||||
Vec3 preferredSpot = pref.getPreferredSpotOnBox(box, eyes, range);
|
||||
Vec3 preferredSpotOnBox = firstHit(box, eyes, preferredSpot);
|
||||
|
||||
if (preferredSpotOnBox != null) {
|
||||
double preferredDist = eyes.distanceToSqr(preferredSpotOnBox);
|
||||
boolean visible = vis.isVisible(eyes, preferredSpotOnBox);
|
||||
if (preferredDist < wallsRangeSq || (visible && preferredDist < rangeSq)) {
|
||||
Rotation rot = Rotation.lookingAt(preferredSpot, eyes);
|
||||
return new RotationWithVector(rot, preferredSpot);
|
||||
}
|
||||
}
|
||||
|
||||
BestRotationTracker tracker = new BestRotationTracker(pref, !prioritizeVisible);
|
||||
|
||||
Vec3 nearestSpot = getNearestPoint(box, eyes);
|
||||
tracker.considerSpot(preferredSpot, box, eyes, vis, rangeSq, wallsRangeSq, nearestSpot);
|
||||
|
||||
scanBoxPoints(eyes, box, spot -> tracker.considerSpot(
|
||||
spot, box, eyes, vis, rangeSq, wallsRangeSq, spot
|
||||
));
|
||||
|
||||
return tracker.bestVisible != null ? tracker.bestVisible : tracker.bestInvisible;
|
||||
}
|
||||
|
||||
public static boolean canSeeBox(Vec3 eyes, AABB box, double range, double wallsRange) {
|
||||
if (eyes == null || box == null) return false;
|
||||
if (box.contains(eyes)) return true;
|
||||
|
||||
double rangeSq = range * range;
|
||||
double wallsRangeSq = wallsRange * wallsRange;
|
||||
|
||||
final boolean[] ok = {false};
|
||||
scanBoxPoints(eyes, box, pos -> {
|
||||
double dist = eyes.distanceToSqr(pos);
|
||||
if (dist > rangeSq) return;
|
||||
boolean visible = RaycastUtil.hasLineOfSightPoint(eyes, pos);
|
||||
if (!visible && dist > wallsRangeSq) return;
|
||||
ok[0] = true;
|
||||
});
|
||||
return ok[0];
|
||||
}
|
||||
|
||||
private static Vec3 firstHit(AABB box, Vec3 from, Vec3 to) {
|
||||
Optional<Vec3> hit = box.clip(from, to);
|
||||
return hit.orElse(null);
|
||||
}
|
||||
|
||||
private static Vec3 getNearestPoint(AABB box, Vec3 eyes) {
|
||||
double x = Mth.clamp(eyes.x, box.minX, box.maxX);
|
||||
double y = Mth.clamp(eyes.y, box.minY, box.maxY);
|
||||
double z = Mth.clamp(eyes.z, box.minZ, box.maxZ);
|
||||
return new Vec3(x, y, z);
|
||||
}
|
||||
|
||||
private static void scanBoxPoints(Vec3 eyes, AABB box, Consumer<Vec3> fn) {
|
||||
boolean outside = projectPointsOnBox(eyes, box, 256, fn);
|
||||
if (!outside) {
|
||||
double[] steps = proportions();
|
||||
for (double x : steps) {
|
||||
for (double y : steps) {
|
||||
for (double z : steps) {
|
||||
Vec3 vec3 = new Vec3(
|
||||
box.minX + box.getXsize() * x,
|
||||
box.minY + box.getYsize() * y,
|
||||
box.minZ + box.getZsize() * z
|
||||
);
|
||||
fn.accept(vec3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean projectPointsOnBox(Vec3 eyes, AABB box, int maxPoints, Consumer<Vec3> fn) {
|
||||
boolean outside = !box.contains(eyes);
|
||||
if (!outside) return false;
|
||||
|
||||
double[] steps = proportions();
|
||||
int count = 0;
|
||||
|
||||
// Faces: X-min/max
|
||||
for (double y : steps) {
|
||||
for (double z : steps) {
|
||||
fn.accept(new Vec3(box.minX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
|
||||
if (++count >= maxPoints) return true;
|
||||
fn.accept(new Vec3(box.maxX, box.minY + box.getYsize() * y, box.minZ + box.getZsize() * z));
|
||||
if (++count >= maxPoints) return true;
|
||||
}
|
||||
}
|
||||
// Faces: Y-min/max
|
||||
for (double x : steps) {
|
||||
for (double z : steps) {
|
||||
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY, box.minZ + box.getZsize() * z));
|
||||
if (++count >= maxPoints) return true;
|
||||
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.maxY, box.minZ + box.getZsize() * z));
|
||||
if (++count >= maxPoints) return true;
|
||||
}
|
||||
}
|
||||
// Faces: Z-min/max
|
||||
for (double x : steps) {
|
||||
for (double y : steps) {
|
||||
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.minZ));
|
||||
if (++count >= maxPoints) return true;
|
||||
fn.accept(new Vec3(box.minX + box.getXsize() * x, box.minY + box.getYsize() * y, box.maxZ));
|
||||
if (++count >= maxPoints) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double[] proportions() {
|
||||
double[] steps = new double[10];
|
||||
double v = 0.05;
|
||||
for (int i = 0; i < steps.length; i++) {
|
||||
steps[i] = v;
|
||||
v += 0.1;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
public interface VisibilityPredicate {
|
||||
boolean isVisible(Vec3 eyesPos, Vec3 targetSpot);
|
||||
}
|
||||
|
||||
private static class BestRotationTracker {
|
||||
private final RotationPreference comparator;
|
||||
private final boolean ignoreVisibility;
|
||||
private RotationWithVector bestVisible;
|
||||
private RotationWithVector bestInvisible;
|
||||
|
||||
private BestRotationTracker(RotationPreference comparator, boolean ignoreVisibility) {
|
||||
this.comparator = comparator;
|
||||
this.ignoreVisibility = ignoreVisibility;
|
||||
}
|
||||
|
||||
private void considerRotation(RotationWithVector rotation, boolean visible) {
|
||||
if (visible || ignoreVisibility) {
|
||||
if (bestVisible == null || comparator.compare(bestVisible.rotation(), rotation.rotation()) > 0) {
|
||||
bestVisible = rotation;
|
||||
}
|
||||
} else {
|
||||
if (bestInvisible == null || comparator.compare(bestInvisible.rotation(), rotation.rotation()) > 0) {
|
||||
bestInvisible = rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void considerSpot(
|
||||
Vec3 preferredSpot,
|
||||
AABB box,
|
||||
Vec3 eyes,
|
||||
VisibilityPredicate visibilityPredicate,
|
||||
double rangeSq,
|
||||
double wallsRangeSq,
|
||||
Vec3 spot
|
||||
) {
|
||||
Vec3 raycastTarget = preferredSpot.subtract(eyes).scale(2.0).add(eyes);
|
||||
Vec3 spotOnBox = firstHit(box, eyes, raycastTarget);
|
||||
if (spotOnBox == null) return;
|
||||
|
||||
double distSq = eyes.distanceToSqr(spotOnBox);
|
||||
boolean visible = visibilityPredicate.isVisible(eyes, spotOnBox);
|
||||
if ((!visible || distSq >= rangeSq) && distSq >= wallsRangeSq) return;
|
||||
|
||||
Rotation rotation = Rotation.lookingAt(spot, eyes);
|
||||
considerRotation(new RotationWithVector(rotation, spot), visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
/*
|
||||
* 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.aiming.rotation;
|
||||
import dev.loki.lovisual.util.aiming.RequestHandler;
|
||||
|
||||
|
||||
import dev.loki.lovisual.util.screen.ClientScreen;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundUseItemPacket;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.Events;
|
||||
import dev.loki.lovisual.events.impl.network.EventSync;
|
||||
import dev.loki.lovisual.events.impl.network.PacketEvent;
|
||||
import dev.loki.lovisual.events.impl.world.PlayerVelocityStrafe;
|
||||
import dev.loki.lovisual.events.impl.input.RotationUpdateEvent;
|
||||
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
|
||||
import dev.loki.lovisual.mixins.accessors.entity.EntityInvoker;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
|
||||
|
||||
public final class RotationManager {
|
||||
|
||||
public static final RotationManager INSTANCE = new RotationManager();
|
||||
|
||||
private final RequestHandler<RotationTarget> rotationTargetHandler = new RequestHandler<>();
|
||||
private RotationTarget previousRotationTarget;
|
||||
private Object previousRotationProvider;
|
||||
|
||||
private Rotation currentRotation;
|
||||
private Rotation previousRotation;
|
||||
private Object currentRotationProvider;
|
||||
private boolean smoothReturnActive;
|
||||
private int smoothReturnTicks;
|
||||
|
||||
private Rotation actualServerRotation = Rotation.ZERO;
|
||||
private Rotation theoreticalServerRotation = Rotation.ZERO;
|
||||
private int lastLifecycleAge = Integer.MIN_VALUE;
|
||||
|
||||
private RotationManager() {
|
||||
}
|
||||
|
||||
private static float computeRotationDifference(Rotation a, Rotation b) {
|
||||
if (a == null || b == null) {
|
||||
return Float.MAX_VALUE;
|
||||
}
|
||||
float yawDiff = Math.abs(RotationUtil.angleDifference(a.yaw(), b.yaw()));
|
||||
float pitchDiff = Math.abs(a.pitch() - b.pitch());
|
||||
return (float) Math.hypot(yawDiff, pitchDiff);
|
||||
}
|
||||
|
||||
public static LocalPlayer player() {
|
||||
return Minecraft.getInstance().player;
|
||||
}
|
||||
|
||||
public static Rotation getPlayerLastRotation() {
|
||||
LocalPlayer player = player();
|
||||
if (player instanceof ILocalPlayer access) {
|
||||
return new Rotation(access.lovisual$getLastYaw(), access.lovisual$getLastPitch(), true);
|
||||
}
|
||||
if (player != null) {
|
||||
return new Rotation(player.getYRot(), player.getXRot(), true);
|
||||
}
|
||||
return Rotation.ZERO;
|
||||
}
|
||||
|
||||
public static double boxedDistanceToPlayer(Entity entity) {
|
||||
LocalPlayer player = player();
|
||||
if (player == null || entity == null) return 0.0;
|
||||
Vec3 eyes = player.getEyePosition();
|
||||
var box = entity.getBoundingBox();
|
||||
double dx = 0.0;
|
||||
if (eyes.x < box.minX) dx = box.minX - eyes.x;
|
||||
else if (eyes.x > box.maxX) dx = eyes.x - box.maxX;
|
||||
double dy = 0.0;
|
||||
if (eyes.y < box.minY) dy = box.minY - eyes.y;
|
||||
else if (eyes.y > box.maxY) dy = eyes.y - box.maxY;
|
||||
double dz = 0.0;
|
||||
if (eyes.z < box.minZ) dz = box.minZ - eyes.z;
|
||||
else if (eyes.z > box.maxZ) dz = eyes.z - box.maxZ;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
public RotationTarget getActiveRotationTarget() {
|
||||
RotationTarget active = rotationTargetHandler.getActiveRequestValue();
|
||||
return active != null ? active : previousRotationTarget;
|
||||
}
|
||||
|
||||
public RotationTarget getPreviousRotationTarget() {
|
||||
return previousRotationTarget;
|
||||
}
|
||||
|
||||
public Rotation getCurrentRotation() {
|
||||
return currentRotation;
|
||||
}
|
||||
|
||||
private void setCurrentRotation(Rotation rotation) {
|
||||
setCurrentRotation(rotation, currentRotationProvider);
|
||||
}
|
||||
|
||||
private void setCurrentRotation(Rotation rotation, Object provider) {
|
||||
if (rotation == null) {
|
||||
if (currentRotation != null) {
|
||||
previousRotation = currentRotation;
|
||||
}
|
||||
currentRotation = null;
|
||||
currentRotationProvider = null;
|
||||
return;
|
||||
}
|
||||
|
||||
previousRotation = currentRotation;
|
||||
currentRotation = rotation;
|
||||
currentRotationProvider = provider;
|
||||
}
|
||||
|
||||
public Rotation getPreviousRotation() {
|
||||
return previousRotation;
|
||||
}
|
||||
|
||||
public Rotation getServerRotation() {
|
||||
return actualServerRotation;
|
||||
}
|
||||
|
||||
public Rotation getMovementRotation() {
|
||||
LocalPlayer player = player();
|
||||
Rotation fallback = player != null
|
||||
? new Rotation(player.getYRot(), player.getXRot(), true)
|
||||
: (currentRotation != null ? currentRotation : Rotation.ZERO);
|
||||
|
||||
RotationTarget active = getActiveRotationTarget();
|
||||
if (currentRotation == null || active == null || active.movementCorrection == MovementCorrection.OFF) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return currentRotation;
|
||||
}
|
||||
|
||||
public void setRotationTarget(RotationTarget plan, int priority, Object provider) {
|
||||
if (plan == null) return;
|
||||
|
||||
rotationTargetHandler.request(new RequestHandler.Request<>(plan.ticksUntilReset, priority, provider, plan));
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
public void setRotationTarget(RotationTarget plan, int priority) {
|
||||
setRotationTarget(plan, priority, null);
|
||||
}
|
||||
|
||||
private boolean isRotatingAllowed(RotationTarget target) {
|
||||
if (!target.considerInventory) return true;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) return true;
|
||||
return !(ClientScreen.current() instanceof AbstractContainerScreen<?>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current rotation to a new rotation step.
|
||||
*/
|
||||
public void update() {
|
||||
LocalPlayer player = player();
|
||||
if (player == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
Rotation playerRotation = new Rotation(player.getYRot(), player.getXRot(), true);
|
||||
RequestHandler.Request<RotationTarget> activeRequest = rotationTargetHandler.getActiveRequest();
|
||||
RotationTarget activeRotationTarget = activeRequest != null ? activeRequest.value() : previousRotationTarget;
|
||||
Object activeProvider = activeRequest != null ? activeRequest.provider() : previousRotationProvider;
|
||||
|
||||
if (activeRotationTarget == null) {
|
||||
if (currentRotation == null || !smoothReturnActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
float diffToPlayer = computeRotationDifference(currentRotation, playerRotation);
|
||||
if (diffToPlayer <= 0.5f) {
|
||||
finishSmoothReturn();
|
||||
return;
|
||||
}
|
||||
|
||||
float speed = 0.25f + 0.4f * Math.min(1.0f, diffToPlayer / 30.0f);
|
||||
float yawDiff = RotationUtil.angleDifference(playerRotation.yaw(), currentRotation.yaw());
|
||||
float newYaw = currentRotation.yaw() + yawDiff * speed;
|
||||
float newPitch = Mth.lerp(speed, currentRotation.pitch(), playerRotation.pitch());
|
||||
setCurrentRotation(new Rotation(newYaw, newPitch, false).normalize(), currentRotationProvider);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRotatingAllowed(activeRotationTarget)) {
|
||||
Rotation fromRotation = resolveBaseRotation(playerRotation);
|
||||
if (activeRequest == null) {
|
||||
if (currentRotation == null) {
|
||||
finishSmoothReturn();
|
||||
return;
|
||||
}
|
||||
if (!smoothReturnActive) {
|
||||
smoothReturnActive = true;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
Rotation resetRotation = activeRotationTarget.towards(fromRotation, true).normalize();
|
||||
setCurrentRotation(resetRotation, activeProvider);
|
||||
previousRotationTarget = activeRotationTarget;
|
||||
previousRotationProvider = activeProvider;
|
||||
|
||||
smoothReturnTicks++;
|
||||
float diffToPlayer = computeRotationDifference(resetRotation, playerRotation);
|
||||
int maxTicks = Math.max(1, activeRotationTarget.ticksUntilReset);
|
||||
float threshold = Math.max(0.05f, activeRotationTarget.resetThreshold);
|
||||
if (diffToPlayer <= threshold || smoothReturnTicks >= maxTicks) {
|
||||
finishSmoothReturn();
|
||||
rotationTargetHandler.tick();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Rotation rotation = activeRotationTarget.towards(fromRotation, false).normalize();
|
||||
setCurrentRotation(rotation, activeProvider);
|
||||
previousRotationTarget = activeRotationTarget;
|
||||
previousRotationProvider = activeProvider;
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
|
||||
if (activeRotationTarget.whenReached != null) {
|
||||
activeRotationTarget.whenReached.invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rotationTargetHandler.tick();
|
||||
}
|
||||
|
||||
private void finishSmoothReturn() {
|
||||
setCurrentRotation(null);
|
||||
previousRotation = null;
|
||||
previousRotationTarget = null;
|
||||
previousRotationProvider = null;
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
private Rotation resolveBaseRotation(Rotation playerRotation) {
|
||||
if (currentRotation != null) {
|
||||
return currentRotation;
|
||||
}
|
||||
if (actualServerRotation != null && actualServerRotation != Rotation.ZERO) {
|
||||
return actualServerRotation;
|
||||
}
|
||||
return playerRotation;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
rotationTargetHandler.clear();
|
||||
previousRotationTarget = null;
|
||||
previousRotationProvider = null;
|
||||
currentRotation = null;
|
||||
currentRotationProvider = null;
|
||||
previousRotation = null;
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
public void clear(Object provider) {
|
||||
if (provider == null) return;
|
||||
boolean removed = rotationTargetHandler.clear(provider);
|
||||
boolean ownsVisualState = provider == currentRotationProvider || provider == previousRotationProvider;
|
||||
if (!removed && !ownsVisualState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRotation != null && ownsVisualState) {
|
||||
smoothReturnActive = true;
|
||||
smoothReturnTicks = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ownsVisualState) {
|
||||
previousRotationTarget = null;
|
||||
previousRotationProvider = null;
|
||||
previousRotation = null;
|
||||
}
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops accepting new rotations from provider, but keeps current/previous state
|
||||
* so update() can return camera smoothly instead of snapping instantly.
|
||||
*/
|
||||
public void release(Object provider) {
|
||||
release(provider, true);
|
||||
}
|
||||
|
||||
public void release(Object provider, boolean smoothReturn) {
|
||||
if (provider == null) return;
|
||||
boolean removed = rotationTargetHandler.clear(provider);
|
||||
boolean ownsVisualState = provider == currentRotationProvider || provider == previousRotationProvider;
|
||||
if (!removed && !ownsVisualState) {
|
||||
return;
|
||||
}
|
||||
if (!smoothReturn) {
|
||||
if (ownsVisualState) {
|
||||
setCurrentRotation(null);
|
||||
previousRotationTarget = null;
|
||||
previousRotationProvider = null;
|
||||
previousRotation = null;
|
||||
}
|
||||
smoothReturnActive = false;
|
||||
smoothReturnTicks = 0;
|
||||
return;
|
||||
}
|
||||
smoothReturnActive = ownsVisualState && currentRotation != null;
|
||||
smoothReturnTicks = 0;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onSync(EventSync e) {
|
||||
RotationTarget activeRotationTarget = getActiveRotationTarget();
|
||||
if (activeRotationTarget == null || currentRotation == null) return;
|
||||
|
||||
e.setRotation(currentRotation.yaw(), currentRotation.pitch(), true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = 100)
|
||||
public void onVelocityStrafe(PlayerVelocityStrafe event) {
|
||||
RotationTarget active = getActiveRotationTarget();
|
||||
if (active == null) return;
|
||||
if (active.movementCorrection == MovementCorrection.OFF) {
|
||||
return;
|
||||
}
|
||||
|
||||
Rotation rotation = getMovementRotation();
|
||||
if (rotation == null) return;
|
||||
|
||||
Vec3 velocity = EntityInvoker.lovisual$movementInputToVelocity(
|
||||
event.getMovementInput(),
|
||||
event.getSpeed(),
|
||||
rotation.yaw()
|
||||
);
|
||||
event.setVelocity(velocity);
|
||||
}
|
||||
|
||||
@EventHandler(priority = -100)
|
||||
public void onPacketSend(PacketEvent.Send e) {
|
||||
if (e.isCancelled()) return;
|
||||
var packet = e.getPacket();
|
||||
|
||||
Rotation rot = null;
|
||||
if (packet instanceof ServerboundMovePlayerPacket move) {
|
||||
if (!move.hasRotation()) return;
|
||||
rot = new Rotation(move.getYRot(0.0f), move.getXRot(0.0f), true);
|
||||
} else if (packet instanceof ServerboundUseItemPacket use) {
|
||||
rot = new Rotation(use.getYRot(), use.getXRot(), true);
|
||||
}
|
||||
|
||||
if (rot != null) {
|
||||
actualServerRotation = rot;
|
||||
theoreticalServerRotation = rot;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = -100)
|
||||
public void onPacketReceive(PacketEvent.Receive e) {
|
||||
if (!(e.getPacket() instanceof net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket packet))
|
||||
return;
|
||||
Rotation rot = new Rotation(packet.change().yRot(), packet.change().xRot(), true);
|
||||
theoreticalServerRotation = rot;
|
||||
actualServerRotation = rot;
|
||||
}
|
||||
|
||||
public void runTickLifecycle() {
|
||||
LocalPlayer player = player();
|
||||
if (player == null) {
|
||||
lastLifecycleAge = Integer.MIN_VALUE;
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.tickCount == lastLifecycleAge) {
|
||||
return;
|
||||
}
|
||||
lastLifecycleAge = player.tickCount;
|
||||
|
||||
Events.BUS.post(new RotationUpdateEvent(RotationUpdateEvent.Type.PRE));
|
||||
update();
|
||||
Events.BUS.post(new RotationUpdateEvent(RotationUpdateEvent.Type.POST));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.aiming.rotation;
|
||||
import dev.loki.lovisual.util.aiming.RestrictedSingleUseAction;
|
||||
|
||||
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import dev.loki.lovisual.util.aiming.data.Rotation;
|
||||
import dev.loki.lovisual.util.aiming.features.MovementCorrection;
|
||||
import dev.loki.lovisual.util.aiming.features.processors.RotationProcessor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class RotationTarget {
|
||||
|
||||
public final Rotation rotation;
|
||||
public final List<RotationProcessor> processors;
|
||||
public final int ticksUntilReset;
|
||||
public final float resetThreshold;
|
||||
public final boolean considerInventory;
|
||||
public final MovementCorrection movementCorrection;
|
||||
public final boolean freeCorrection;
|
||||
public final RestrictedSingleUseAction whenReached;
|
||||
public Entity entity;
|
||||
|
||||
public RotationTarget(Rotation rotation,
|
||||
Entity entity,
|
||||
List<RotationProcessor> processors,
|
||||
int ticksUntilReset,
|
||||
float resetThreshold,
|
||||
boolean considerInventory,
|
||||
MovementCorrection movementCorrection,
|
||||
RestrictedSingleUseAction whenReached) {
|
||||
this(rotation, entity, processors, ticksUntilReset, resetThreshold, considerInventory,
|
||||
movementCorrection, movementCorrection == MovementCorrection.SILENT, whenReached);
|
||||
}
|
||||
|
||||
public RotationTarget(Rotation rotation,
|
||||
Entity entity,
|
||||
List<RotationProcessor> processors,
|
||||
int ticksUntilReset,
|
||||
float resetThreshold,
|
||||
boolean considerInventory,
|
||||
MovementCorrection movementCorrection,
|
||||
boolean freeCorrection,
|
||||
RestrictedSingleUseAction whenReached) {
|
||||
this.rotation = rotation;
|
||||
this.entity = entity;
|
||||
this.processors = processors != null ? processors : List.of();
|
||||
this.ticksUntilReset = ticksUntilReset;
|
||||
this.resetThreshold = resetThreshold;
|
||||
this.considerInventory = considerInventory;
|
||||
this.movementCorrection = movementCorrection != null ? movementCorrection : MovementCorrection.SILENT;
|
||||
this.freeCorrection = freeCorrection && this.movementCorrection == MovementCorrection.SILENT;
|
||||
this.whenReached = whenReached;
|
||||
}
|
||||
|
||||
public Rotation towards(Rotation currentRotation, boolean isResetting) {
|
||||
if (isResetting) {
|
||||
this.entity = null;
|
||||
var player = RotationManager.player();
|
||||
Rotation base = player != null ? new Rotation(player.getYRot(), player.getXRot(), false) : Rotation.ZERO;
|
||||
return process(currentRotation, base);
|
||||
}
|
||||
|
||||
return process(currentRotation, rotation);
|
||||
}
|
||||
|
||||
private Rotation process(Rotation currentRotation, Rotation targetRotation) {
|
||||
if (processors.isEmpty()) {
|
||||
return targetRotation;
|
||||
}
|
||||
|
||||
Rotation out = targetRotation;
|
||||
Rotation base = currentRotation;
|
||||
for (RotationProcessor processor : processors) {
|
||||
out = processor.process(this, base, out);
|
||||
base = out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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.aiming.rotation;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Input;
|
||||
import net.minecraft.world.phys.Vec2;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Small rotation helpers (yaw/pitch).
|
||||
*/
|
||||
public enum RotationUtil {
|
||||
;
|
||||
|
||||
public static double gcd() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) return 0.0;
|
||||
double sens = mc.options.sensitivity().get();
|
||||
double f = sens * 0.6 + 0.2;
|
||||
return f * f * f * 8.0 * 0.15;
|
||||
}
|
||||
|
||||
public static float[] getRotations(Vec3 from, Vec3 to) {
|
||||
double dx = to.x - from.x;
|
||||
double dy = to.y - from.y;
|
||||
double dz = to.z - from.z;
|
||||
double distXZ = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
float yaw = (float) (Math.toDegrees(Math.atan2(dz, dx)) - 90.0);
|
||||
float pitch = (float) (-Math.toDegrees(Math.atan2(dy, distXZ)));
|
||||
return new float[]{wrapDegrees(yaw), Mth.clamp(wrapDegrees(pitch), -90.0f, 90.0f)};
|
||||
}
|
||||
|
||||
public static float[] getRotationsToEntity(Entity from, Entity target) {
|
||||
Vec3 src = from.getEyePosition();
|
||||
Vec3 dst = target.position().add(0.0, target.getBbHeight() * 0.5, 0.0);
|
||||
if (target instanceof LivingEntity living) {
|
||||
dst = living.getEyePosition();
|
||||
}
|
||||
return getRotations(src, dst);
|
||||
}
|
||||
|
||||
public static float wrapDegrees(float degrees) {
|
||||
return Mth.wrapDegrees(degrees);
|
||||
}
|
||||
|
||||
public static float angleDifference(float a, float b) {
|
||||
return wrapDegrees(a - b);
|
||||
}
|
||||
|
||||
public static float directionAngleTo(Entity from, Entity target) {
|
||||
float[] rot = getRotationsToEntity(from, target);
|
||||
return Math.abs(angleDifference(from.getYRot(), rot[0]));
|
||||
}
|
||||
|
||||
public static int estimateTicksToReach(float currentYaw, float currentPitch,
|
||||
float targetYaw, float targetPitch,
|
||||
float maxTurnPerTick) {
|
||||
if (maxTurnPerTick <= 0.0f) return 1;
|
||||
float yawDiff = Math.abs(angleDifference(currentYaw, targetYaw));
|
||||
float pitchDiff = Math.abs(angleDifference(currentPitch, targetPitch));
|
||||
float maxDiff = Math.max(yawDiff, pitchDiff);
|
||||
return Math.max(1, (int) Math.ceil(maxDiff / maxTurnPerTick));
|
||||
}
|
||||
|
||||
public static Vec3 getRotationVector(float pitch, float yaw) {
|
||||
return Vec3.directionFromRotation(pitch, yaw);
|
||||
}
|
||||
|
||||
public static float withFixedYaw(float currentYaw, float targetYaw) {
|
||||
return targetYaw + angleDifference(currentYaw, targetYaw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate movement input from one yaw-space to another (used for silent rotations).
|
||||
*/
|
||||
public static Vec2 rotateMovementInput(Vec2 input, float fromYaw, float toYaw) {
|
||||
Vec2 world = inputToWorld(input, fromYaw);
|
||||
return worldToInput(world, toYaw);
|
||||
}
|
||||
|
||||
public static Input correctMovementInput(Input input, float fromYaw, float toYaw) {
|
||||
if (input == null) return null;
|
||||
|
||||
float forward = impulse(input.forward(), input.backward());
|
||||
float sideways = impulse(input.left(), input.right());
|
||||
if (forward == 0.0f && sideways == 0.0f) {
|
||||
return input;
|
||||
}
|
||||
|
||||
float deltaYaw = fromYaw - toYaw;
|
||||
float radians = deltaYaw * Mth.DEG_TO_RAD;
|
||||
|
||||
float newX = sideways * Mth.cos(radians) - forward * Mth.sin(radians);
|
||||
float newZ = forward * Mth.cos(radians) + sideways * Mth.sin(radians);
|
||||
|
||||
int movementSideways = Math.round(newX);
|
||||
int movementForward = Math.round(newZ);
|
||||
|
||||
return new Input(
|
||||
movementForward > 0,
|
||||
movementForward < 0,
|
||||
movementSideways > 0,
|
||||
movementSideways < 0,
|
||||
input.jump(),
|
||||
input.shift(),
|
||||
input.sprint()
|
||||
);
|
||||
}
|
||||
|
||||
private static float impulse(boolean positive, boolean negative) {
|
||||
if (positive == negative) return 0.0f;
|
||||
return positive ? 1.0f : -1.0f;
|
||||
}
|
||||
|
||||
private static Vec2 inputToWorld(Vec2 input, float yaw) {
|
||||
double rad = Math.toRadians(yaw);
|
||||
double sin = Math.sin(rad);
|
||||
double cos = Math.cos(rad);
|
||||
float worldX = (float) (input.x * cos - input.y * sin);
|
||||
float worldZ = (float) (input.y * cos + input.x * sin);
|
||||
return new Vec2(worldX, worldZ);
|
||||
}
|
||||
|
||||
private static Vec2 worldToInput(Vec2 world, float yaw) {
|
||||
double rad = Math.toRadians(yaw);
|
||||
double sin = Math.sin(rad);
|
||||
double cos = Math.cos(rad);
|
||||
float inputX = (float) (world.x * cos + world.y * sin);
|
||||
float inputY = (float) (world.y * cos - world.x * sin);
|
||||
return new Vec2(inputX, inputY);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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.animation;
|
||||
|
||||
/**
|
||||
* Кривые анимации для equip-движений руки. Чистая математика без Minecraft.
|
||||
*/
|
||||
public enum EaseCurves {
|
||||
;
|
||||
|
||||
/**
|
||||
* Ease-out-back: перелетает чуть дальше 1.0 перед установкой, давая движению вес.
|
||||
*
|
||||
* @param c1 сила «отдачи» (оригинал — 1.70158)
|
||||
*/
|
||||
public static float easeOutBack(float x, float c1) {
|
||||
float c3 = c1 + 1.0f;
|
||||
float t = x - 1.0f;
|
||||
return 1.0f + c3 * t * t * t + c1 * t * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Перекладывает линейный [0,1] equip-прогресс через ease-out-back,
|
||||
* затем подмешивает по intensity: 0 — ванильная линейность, 1 — полная кривая.
|
||||
*/
|
||||
public static float shapeEquipProgress(float linear, float c1, float intensity) {
|
||||
float eased = easeOutBack(linear, c1);
|
||||
float amount = Math.max(0.0f, Math.min(1.0f, intensity));
|
||||
return linear + (eased - linear) * amount;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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.animation;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.camera.ViewModel;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
|
||||
|
||||
/**
|
||||
* Единая цепочка обработки equip-прогресса руки: сначала кривая HoldMyItems
|
||||
* (перелёт через ease-out-back на полном диапазоне 0..1), затем занижение
|
||||
* амплитуды от ViewModel. Порядок важен: кривая должна получать полный
|
||||
* диапазон, а ViewModel — масштабировать уже готовое движение, иначе
|
||||
* конечная позиция предмета уезжает.
|
||||
*/
|
||||
public enum EquipProgressComposer {
|
||||
;
|
||||
|
||||
public static float compose(float linear,
|
||||
boolean curveEnabled, float curveC1, float curveIntensity,
|
||||
boolean lowerEnabled, float lowering) {
|
||||
float progress = linear;
|
||||
if (curveEnabled) {
|
||||
progress = EaseCurves.shapeEquipProgress(progress, curveC1, curveIntensity);
|
||||
}
|
||||
if (lowerEnabled) {
|
||||
progress *= lowering;
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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.animation;
|
||||
|
||||
/**
|
||||
* Frame-rate independent smooth 0..1 transition value driven by a boolean state.
|
||||
* <p>
|
||||
* The contract is intentionally strict so animations can never fight each other:
|
||||
* the owner computes the target once per frame with {@link #setTarget(boolean)}
|
||||
* and advances the value exactly once per frame with {@link #update(float)}.
|
||||
* Renderers only read {@link #value()}. The value approaches the target with
|
||||
* exponential easing {@code value += (target - value) * (1 - exp(-speed * delta))},
|
||||
* which is monotonic, symmetric for appear/disappear, and identical in wall-clock
|
||||
* time regardless of frame rate.
|
||||
* <p>
|
||||
* This class is pure Java: no Minecraft types, safe to unit test.
|
||||
*/
|
||||
public final class SmoothValue {
|
||||
|
||||
/**
|
||||
* When the remaining distance to the target drops below this, the value snaps
|
||||
* exactly onto the target so it converges in finite time instead of forever
|
||||
* trailing a microscopic difference.
|
||||
*/
|
||||
private static final float CONVERGENCE_EPSILON = 1.0e-4f;
|
||||
|
||||
private final float speed;
|
||||
private float value;
|
||||
private float target;
|
||||
|
||||
/**
|
||||
* @param speed approach rate in 1/delta units; higher is faster.
|
||||
* With delta measured in ticks (20 per second), a speed of
|
||||
* {@code s} needs roughly {@code ln(100) / s} ticks to close 99% of a transition.
|
||||
*/
|
||||
public SmoothValue(float speed) {
|
||||
this(speed, 0.0f);
|
||||
}
|
||||
|
||||
public SmoothValue(float speed, float initialValue) {
|
||||
if (!isFinite(speed) || speed <= 0.0f) {
|
||||
throw new IllegalArgumentException("speed must be finite and positive: " + speed);
|
||||
}
|
||||
this.speed = speed;
|
||||
this.value = clamp01(initialValue);
|
||||
this.target = this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the goal for the next {@link #update(float)} calls: 1 when the animated
|
||||
* state is active, 0 when it is not. Does not move the value by itself.
|
||||
*/
|
||||
public void setTarget(boolean active) {
|
||||
target = active ? 1.0f : 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the value one step toward the target. Call exactly once per frame.
|
||||
*
|
||||
* @param delta elapsed time since the previous update, in the same units the
|
||||
* speed was configured for (ticks in this client). Non-positive
|
||||
* or broken deltas are ignored.
|
||||
*/
|
||||
public void update(float delta) {
|
||||
if (!isFinite(delta) || delta <= 0.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
float factor = 1.0f - (float) Math.exp(-speed * delta);
|
||||
if (factor <= 0.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
value += (target - value) * factor;
|
||||
value = clamp01(value);
|
||||
|
||||
if (Math.abs(target - value) <= CONVERGENCE_EPSILON) {
|
||||
value = target;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teleports both value and target: no transition, the next frame renders settled.
|
||||
*/
|
||||
public void setInstant(boolean active) {
|
||||
target = active ? 1.0f : 0.0f;
|
||||
value = target;
|
||||
}
|
||||
|
||||
/** Current smoothed position in 0..1. */
|
||||
public float value() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Current goal in 0..1 as last set by {@link #setTarget(boolean)}. */
|
||||
public float target() {
|
||||
return target;
|
||||
}
|
||||
|
||||
/** True once the value has fully reached the target. */
|
||||
public boolean isSettled() {
|
||||
return value == target;
|
||||
}
|
||||
|
||||
private static float clamp01(float v) {
|
||||
if (v < 0.0f) return 0.0f;
|
||||
if (v > 1.0f) return 1.0f;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static boolean isFinite(float v) {
|
||||
return !Float.isNaN(v) && !Float.isInfinite(v);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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.animation;
|
||||
|
||||
/**
|
||||
* Чистая математика трёхфазной анимации (появление/жизнь/исчезновение) —
|
||||
* идея из Soup HitBubbles (ThreeStageAnimation), без библиотеки именованных
|
||||
* интерполяций референса: appear — ease-out (быстрый старт), disappear —
|
||||
* ease-in (медленный старт затухания).
|
||||
*/
|
||||
public enum ThreeStageProgress {
|
||||
;
|
||||
|
||||
public enum Stage { APPEAR, EXIST, DISAPPEAR, FINISHED }
|
||||
|
||||
public static Stage stage(double elapsedMs, double appearMs, double existMs, double disappearMs) {
|
||||
if (elapsedMs < appearMs) return Stage.APPEAR;
|
||||
if (elapsedMs < appearMs + existMs) return Stage.EXIST;
|
||||
if (elapsedMs < appearMs + existMs + disappearMs) return Stage.DISAPPEAR;
|
||||
return Stage.FINISHED;
|
||||
}
|
||||
|
||||
public static boolean isFinished(double elapsedMs, double appearMs, double existMs, double disappearMs) {
|
||||
return stage(elapsedMs, appearMs, existMs, disappearMs) == Stage.FINISHED;
|
||||
}
|
||||
|
||||
/** @return общая "видимость" 0..1: растёт на appear, держится 1 на exist, падает на disappear. */
|
||||
public static float visibility(double elapsedMs, double appearMs, double existMs, double disappearMs) {
|
||||
Stage stage = stage(elapsedMs, appearMs, existMs, disappearMs);
|
||||
return switch (stage) {
|
||||
case APPEAR -> easeOut(clamp01(safeDiv(elapsedMs, appearMs)));
|
||||
case EXIST -> 1.0f;
|
||||
case DISAPPEAR -> 1.0f - easeIn(clamp01(safeDiv(elapsedMs - appearMs - existMs, disappearMs)));
|
||||
case FINISHED -> 0.0f;
|
||||
};
|
||||
}
|
||||
|
||||
private static float easeOut(float t) {
|
||||
float inv = 1.0f - t;
|
||||
return 1.0f - inv * inv;
|
||||
}
|
||||
|
||||
private static float easeIn(float t) {
|
||||
return t * t;
|
||||
}
|
||||
|
||||
private static double safeDiv(double value, double divisor) {
|
||||
return divisor <= 0.0 ? 1.0 : value / divisor;
|
||||
}
|
||||
|
||||
private static float clamp01(double v) {
|
||||
if (Double.isNaN(v)) return 0.0f;
|
||||
if (v < 0.0) return 0.0f;
|
||||
if (v > 1.0) return 1.0f;
|
||||
return (float) v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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.animation.hand;
|
||||
|
||||
/**
|
||||
* Чистая математика синусоидального покачивания предмета при ходьбе/беге.
|
||||
* <p>
|
||||
* Фаза берётся из накопителя шагов игрока ({@code walkAnimation.position()}),
|
||||
* амплитуда — из нормализованной скорости ({@code walkAnimation.speed()},
|
||||
* 0..1). Один цикл латерали соответствует двум шагам, вертикаль проседает
|
||||
* дважды за цикл с лёгким сдвигом фазы (как у ванильного боба камеры).
|
||||
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
|
||||
*/
|
||||
public enum BobMath {
|
||||
;
|
||||
|
||||
/** Радиан на единицу пройденной фазы (та же частота шагов, что у конечностей). */
|
||||
public static final float STEP_RATE = 0.6662f;
|
||||
|
||||
/** Сдвиг фазы вертикали, чтобы проседание не совпадало с крайними точками латерали. */
|
||||
static final float VERTICAL_PHASE_OFFSET = 0.2f;
|
||||
|
||||
/**
|
||||
* Амплитуда боба по нормализованной скорости: 0 — стоим, 1 — бег/полная ходьба.
|
||||
* Некорректные входы (NaN/Inf) дают 0.
|
||||
*/
|
||||
public static float amplitude(float speed01) {
|
||||
if (Float.isNaN(speed01) || Float.isInfinite(speed01)) {
|
||||
return 0.0f;
|
||||
}
|
||||
if (speed01 <= 0.0f) {
|
||||
return 0.0f;
|
||||
}
|
||||
return Math.min(1.0f, speed01);
|
||||
}
|
||||
|
||||
/** Латеральная компонента, -1..1, период — два шага. */
|
||||
public static float lateral(float phase) {
|
||||
return (float) Math.sin(phase * STEP_RATE);
|
||||
}
|
||||
|
||||
/** Вертикальная компонента, -1..0: проседание дважды за цикл. */
|
||||
public static float vertical(float phase) {
|
||||
float c = (float) Math.cos(phase * STEP_RATE - VERTICAL_PHASE_OFFSET);
|
||||
return -Math.abs(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Точка фазы, где латеральная компонента достигает максимума:
|
||||
* полезна тестам для проверки симметрии цикла.
|
||||
*/
|
||||
public static float lateralPeakPhase() {
|
||||
return (float) (Math.PI / 2.0 / STEP_RATE);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* 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.animation.hand;
|
||||
import dev.loki.lovisual.util.animation.hand.state.HandAnimState;
|
||||
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.HumanoidArm;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.items.held.HoldMyItems;
|
||||
import dev.loki.lovisual.render.helpers.util.TickDelta;
|
||||
|
||||
/**
|
||||
* Клей между рендером руки и чистой математикой динамики: собирает ввод
|
||||
* (дельты взгляда, скорость, использование предмета), собирает позу и
|
||||
* применяет её к матрице вокруг ванильных трансформов. Состояние не хранит —
|
||||
* оно живёт в {@link HandAnimState}, обновляемом раз за кадр.
|
||||
*/
|
||||
public final class HandDynamics {
|
||||
|
||||
/** Смещения/углы слабее этого считаются нулевыми и не применяются. */
|
||||
private static final float SIGNIFICANT_EPSILON = 1.0e-4f;
|
||||
|
||||
private HandDynamics() {
|
||||
}
|
||||
|
||||
/** Один шаг динамики за кадр; вызывается из начала submitHandsWithItems. */
|
||||
public static void beginFrame(HandAnimState state, AbstractClientPlayer player, float tickDelta) {
|
||||
state.beginFrame(
|
||||
player,
|
||||
Minecraft.getInstance().level,
|
||||
player.getViewYRot(tickDelta),
|
||||
player.getViewXRot(tickDelta),
|
||||
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.MAIN_HAND,
|
||||
player.isUsingItem() && player.getUsedItemHand() == InteractionHand.OFF_HAND,
|
||||
TickDelta.fixedDeltaTicks()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет позу динамики к матрице руки: pushPose, затем смещение и
|
||||
* повороты вокруг точки взгляда до ванильных трансформов. Вызывающий
|
||||
* обязан сделать popPose, если вернулось true.
|
||||
*
|
||||
* @return true, если был pushPose и нужен popPose
|
||||
*/
|
||||
public static boolean apply(
|
||||
HoldMyItems module, HandAnimState state, AbstractClientPlayer player,
|
||||
float tickDelta, InteractionHand hand, ItemStack item, PoseStack matrices
|
||||
) {
|
||||
boolean mainHand = hand == InteractionHand.MAIN_HAND;
|
||||
float bobStrength = module.walkBob.get() ? module.bobStrength.get() : 0.0f;
|
||||
float driftStrength = module.useDrift.get() ? module.driftStrength.get() : 0.0f;
|
||||
|
||||
boolean usingThisHand = player.isUsingItem() && player.getUsedItemHand() == hand;
|
||||
float drift = usingThisHand ? state.drift(mainHand) : 0.0f;
|
||||
boolean rightArm = (player.getMainArm() == HumanoidArm.RIGHT) == mainHand;
|
||||
|
||||
HandPoseMath.Pose pose = HandPoseMath.sway(
|
||||
state.sway().yawLag(), state.sway().pitchLag(), module.swayIntensity.get());
|
||||
if (bobStrength > 0.0f) {
|
||||
pose = HandPoseMath.add(pose, HandPoseMath.bob(
|
||||
player.walkAnimation.position(tickDelta),
|
||||
BobMath.amplitude(player.walkAnimation.speed(tickDelta)),
|
||||
bobStrength));
|
||||
}
|
||||
if (driftStrength > 0.0f && drift > 0.0f) {
|
||||
pose = HandPoseMath.add(pose, HandPoseMath.drift(drift, driftStrength, rightArm));
|
||||
}
|
||||
if (module.joinAppear.get()) {
|
||||
pose = HandPoseMath.add(pose, HandPoseMath.joinDrop(state.joinProgress()));
|
||||
}
|
||||
if (module.gripEnabled.get() && !usingThisHand && HeldAlongFist.test(item)) {
|
||||
float side = rightArm ? 1.0f : -1.0f;
|
||||
pose = HandPoseMath.add(pose, HandPoseMath.grip(
|
||||
module.gripExtend.get(), module.gripSlide.get(), module.gripOut.get(),
|
||||
module.gripForward.get(), module.gripTilt.get(), module.gripTurn.get(),
|
||||
module.gripRoll.get(), side));
|
||||
}
|
||||
if (!pose.isSignificant(SIGNIFICANT_EPSILON)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
matrices.pushPose();
|
||||
matrices.translate(pose.dx(), pose.dy(), pose.dz());
|
||||
if (pose.rx() != 0.0f) matrices.mulPose(Axis.XP.rotationDegrees(pose.rx()));
|
||||
if (pose.ry() != 0.0f) matrices.mulPose(Axis.YP.rotationDegrees(pose.ry()));
|
||||
if (pose.rz() != 0.0f) matrices.mulPose(Axis.ZP.rotationDegrees(pose.rz()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
* 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.animation.hand;
|
||||
|
||||
/**
|
||||
* Сборка итоговой позы руки из отдельных эффектов: sway от обзора, боб от
|
||||
* ходьбы, дрейф к центру при использовании предмета и «въезд» при входе в мир.
|
||||
* <p>
|
||||
* Конвенции знаков соответствуют пространству ванильного рендера руки:
|
||||
* +X — влево на экране (правая рука стоит при X<0), +Y — вверх, +Z — к
|
||||
* зрителю; углы в градусах. Смещения — в условных блоках.
|
||||
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
|
||||
*/
|
||||
public enum HandPoseMath {
|
||||
;
|
||||
|
||||
/** Нулевая поза — применять трансформы не нужно. */
|
||||
public static final Pose ZERO = new Pose(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
|
||||
|
||||
private static final float SWAY_POS_PER_DEG = 0.010f;
|
||||
private static final float SWAY_ROT_Y_PER_DEG = 1.4f;
|
||||
private static final float SWAY_ROT_X_PER_DEG = 1.1f;
|
||||
private static final float SWAY_ROT_Z_PER_DEG = 0.5f;
|
||||
|
||||
private static final float BOB_POS_X = 0.045f;
|
||||
private static final float BOB_POS_Y = 0.035f;
|
||||
private static final float BOB_ROT_Z_DEG = 2.5f;
|
||||
private static final float BOB_ROT_X_DEG = 3.0f;
|
||||
|
||||
private static final float DRIFT_POS_X = 0.10f;
|
||||
private static final float DRIFT_POS_Z = 0.05f;
|
||||
private static final float DRIFT_ROT_Y_DEG = 10.0f;
|
||||
private static final float DRIFT_ROT_Z_DEG = 9.0f;
|
||||
|
||||
private static final float JOIN_DROP = 0.85f;
|
||||
private static final float JOIN_TILT_DEG = 18.0f;
|
||||
|
||||
/**
|
||||
* Отставание от поворота камеры: камера повернула вправо (yaw+) — предмет
|
||||
* сместился влево на экране (+X) и довернулся назад (ry-), взгляд вниз
|
||||
* (pitch+) — предмет приподнялся (+Y) и задрался (rx-).
|
||||
*/
|
||||
public static Pose sway(float yawLagDeg, float pitchLagDeg, float strength) {
|
||||
float s = scale(strength);
|
||||
if (s <= 0.0f) {
|
||||
return ZERO;
|
||||
}
|
||||
return new Pose(
|
||||
yawLagDeg * SWAY_POS_PER_DEG * s,
|
||||
pitchLagDeg * SWAY_POS_PER_DEG * s,
|
||||
0.0f,
|
||||
-pitchLagDeg * SWAY_ROT_X_PER_DEG * s,
|
||||
-yawLagDeg * SWAY_ROT_Y_PER_DEG * s,
|
||||
yawLagDeg * SWAY_ROT_Z_PER_DEG * s
|
||||
);
|
||||
}
|
||||
|
||||
/** Покачивание при ходьбе: {@code phase} — позиция шага, {@code amplitude01} — 0..1 от скорости. */
|
||||
public static Pose bob(float phase, float amplitude01, float strength) {
|
||||
float a = clamp01(amplitude01) * scale(strength);
|
||||
if (a <= 0.0f) {
|
||||
return ZERO;
|
||||
}
|
||||
float lateral = BobMath.lateral(phase);
|
||||
float vertical = BobMath.vertical(phase);
|
||||
return new Pose(
|
||||
-lateral * BOB_POS_X * a,
|
||||
vertical * BOB_POS_Y * a,
|
||||
0.0f,
|
||||
vertical * BOB_ROT_X_DEG * a,
|
||||
0.0f,
|
||||
-lateral * BOB_ROT_Z_DEG * a
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Дрейф используемого предмета (еда/зелье/щит) к центру экрана.
|
||||
* Правая рука тянется влево (+X), левая — вправо (-X), плюс лёгкий
|
||||
* наклон внутрь.
|
||||
*/
|
||||
public static Pose drift(float progress01, float strength, boolean rightArm) {
|
||||
float s = clamp01(progress01) * scale(strength);
|
||||
if (s <= 0.0f) {
|
||||
return ZERO;
|
||||
}
|
||||
float side = rightArm ? 1.0f : -1.0f;
|
||||
return new Pose(
|
||||
side * DRIFT_POS_X * s,
|
||||
0.0f,
|
||||
-DRIFT_POS_Z * s,
|
||||
0.0f,
|
||||
-side * DRIFT_ROT_Y_DEG * s,
|
||||
side * DRIFT_ROT_Z_DEG * s
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Хват вдоль кулака" (grip) для предметов с рукоятью (см. {@link HeldAlongFist}):
|
||||
* лёгкий сдвиг ближе к камере/в сторону и поворот — имитирует держание меча/палки
|
||||
* не прямо по-вертикали ванильного equip, а слегка наискось в кулаке.
|
||||
* {@code side}: +1 — правая рука, -1 — левая.
|
||||
*/
|
||||
public static Pose grip(float extend, float slide, float out, float forward,
|
||||
float tilt, float turn, float roll, float side) {
|
||||
return new Pose(
|
||||
0.32f * side * extend + out * side,
|
||||
slide,
|
||||
-0.95f * extend - forward,
|
||||
tilt,
|
||||
turn * side,
|
||||
roll * side
|
||||
);
|
||||
}
|
||||
|
||||
/** Появление при входе в мир: предмет въезжает снизу и распрямляется. */
|
||||
public static Pose joinDrop(float progress01) {
|
||||
float p = clamp01(progress01);
|
||||
float remain = 1.0f - p;
|
||||
if (remain <= 0.0f) {
|
||||
return ZERO;
|
||||
}
|
||||
float square = remain * remain;
|
||||
return new Pose(0.0f, -JOIN_DROP * square, 0.0f, JOIN_TILT_DEG * square, 0.0f, 0.0f);
|
||||
}
|
||||
|
||||
/** Поэлементная сумма поз. */
|
||||
public static Pose add(Pose a, Pose b) {
|
||||
if (a == ZERO) return b;
|
||||
if (b == ZERO) return a;
|
||||
return new Pose(
|
||||
a.dx() + b.dx(),
|
||||
a.dy() + b.dy(),
|
||||
a.dz() + b.dz(),
|
||||
a.rx() + b.rx(),
|
||||
a.ry() + b.ry(),
|
||||
a.rz() + b.rz()
|
||||
);
|
||||
}
|
||||
|
||||
static float clamp01(float v) {
|
||||
if (Float.isNaN(v)) return 0.0f;
|
||||
if (v < 0.0f) return 0.0f;
|
||||
if (v > 1.0f) return 1.0f;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Множитель силы настройки: линейный, без потолка — диапазон настроек 0..2
|
||||
* должен именно усиливать эффект, а не обрезаться. Отрицательные и
|
||||
* сломанные значения дают ноль.
|
||||
*/
|
||||
static float scale(float strength) {
|
||||
if (Float.isNaN(strength) || Float.isInfinite(strength)) return 0.0f;
|
||||
return Math.max(0.0f, strength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Итоговая поза руки: смещение (dx, dy, dz) и поворот (rx, ry, rz в градусах),
|
||||
* применяемые вокруг точки взгляда до ванильных трансформов руки.
|
||||
*/
|
||||
public record Pose(float dx, float dy, float dz, float rx, float ry, float rz) {
|
||||
|
||||
/** Все компоненты по модулю не больше epsilon — трансформ можно не применять. */
|
||||
public boolean isSignificant(float epsilon) {
|
||||
return Math.abs(dx) > epsilon
|
||||
|| Math.abs(dy) > epsilon
|
||||
|| Math.abs(dz) > epsilon
|
||||
|| Math.abs(rx) > epsilon
|
||||
|| Math.abs(ry) > epsilon
|
||||
|| Math.abs(rz) > epsilon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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.animation.hand;
|
||||
|
||||
import net.minecraft.tags.ItemTags;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/**
|
||||
* Предметы с рукоятью, которые выглядят естественно при "хвате вдоль кулака"
|
||||
* (grip): мечи/инструменты/палки. Портировано из идеи Delta HMI
|
||||
* (heldAlongTheFist), список расширен по нашему набору ванильных предметов.
|
||||
*/
|
||||
public enum HeldAlongFist {
|
||||
;
|
||||
|
||||
public static boolean test(ItemStack item) {
|
||||
if (item == null || item.isEmpty()) return false;
|
||||
return item.is(ItemTags.SWORDS) || item.is(ItemTags.AXES) || item.is(ItemTags.PICKAXES)
|
||||
|| item.is(ItemTags.SHOVELS) || item.is(ItemTags.HOES)
|
||||
|| item.is(Items.MACE) || item.is(Items.TRIDENT)
|
||||
|| item.is(Items.FISHING_ROD) || item.is(Items.CARROT_ON_A_STICK)
|
||||
|| item.is(Items.WARPED_FUNGUS_ON_A_STICK) || item.is(Items.STICK)
|
||||
|| item.is(Items.BLAZE_ROD) || item.is(Items.BREEZE_ROD)
|
||||
|| item.is(Items.BONE) || item.is(Items.DEBUG_STICK);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* 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.animation.hand.state;
|
||||
|
||||
import dev.loki.lovisual.util.animation.SmoothValue;
|
||||
|
||||
/**
|
||||
* Покадровое состояние динамики рук: сводит sway, дрейф использования и
|
||||
* появление при входе в мир. Обновляется ровно один раз за кадр
|
||||
* ({@link #beginFrame}), рендер только читает значения — это исключает
|
||||
* двойные шаги, когда кадр рендерит обе руки.
|
||||
* <p>
|
||||
* Player/level передаются как Object-идентичности: смена игрока или мира
|
||||
* перезапускает анимацию появления и гасит ложные дельты взгляда.
|
||||
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
|
||||
*/
|
||||
public final class HandAnimState {
|
||||
|
||||
/** Резкая смена взгляда больше этих пределов считается телепортом, а не движением мыши. */
|
||||
static final float TELEPORT_YAW_DEG = 45.0f;
|
||||
static final float TELEPORT_PITCH_DEG = 25.0f;
|
||||
static final float MAX_FRAME_DELTA_TICKS = 5.0f;
|
||||
|
||||
/** Скорость дрейфа использования (99% за ~ln(100)/speed тиков). */
|
||||
static final float DRIFT_SPEED = 1.8f;
|
||||
/** Скорость появления при входе: ~10 тиков до почти полной посадки. */
|
||||
static final float JOIN_SPEED = 0.45f;
|
||||
|
||||
private final SwayState sway = new SwayState();
|
||||
private final SmoothValue driftMain = new SmoothValue(DRIFT_SPEED);
|
||||
private final SmoothValue driftOff = new SmoothValue(DRIFT_SPEED);
|
||||
private final SmoothValue join = new SmoothValue(JOIN_SPEED);
|
||||
|
||||
private float lastYaw = Float.NaN;
|
||||
private float lastPitch = Float.NaN;
|
||||
private Object lastPlayer;
|
||||
private Object lastLevel;
|
||||
|
||||
/**
|
||||
* Один шаг на кадр.
|
||||
*
|
||||
* @param player идентичность игрока (для смены мира/респавна)
|
||||
* @param level идентичность мира
|
||||
* @param yaw текущий yaw взгляда, градусы (любой диапазон)
|
||||
* @param pitch текущий pitch взгляда, градусы
|
||||
* @param usingMain предмет используется в главной руке
|
||||
* @param usingOff предмет используется во второй руке
|
||||
* @param deltaTicks реальное время кадра в тиках
|
||||
*/
|
||||
public void beginFrame(Object player, Object level, float yaw, float pitch,
|
||||
boolean usingMain, boolean usingOff, float deltaTicks) {
|
||||
if (player == null) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (player != lastPlayer || level != lastLevel) {
|
||||
lastPlayer = player;
|
||||
lastLevel = level;
|
||||
join.setInstant(false);
|
||||
lastYaw = Float.NaN;
|
||||
lastPitch = Float.NaN;
|
||||
}
|
||||
|
||||
float dt = SwayState.sanitize(deltaTicks);
|
||||
if (Float.isNaN(lastYaw)) {
|
||||
lastYaw = yaw;
|
||||
lastPitch = pitch;
|
||||
return;
|
||||
}
|
||||
|
||||
float deltaYaw = SwayState.wrapDegrees(yaw - lastYaw);
|
||||
float deltaPitch = pitch - lastPitch;
|
||||
lastYaw = yaw;
|
||||
lastPitch = pitch;
|
||||
|
||||
boolean lookJump = Math.abs(deltaYaw) > TELEPORT_YAW_DEG
|
||||
|| Math.abs(deltaPitch) > TELEPORT_PITCH_DEG;
|
||||
|
||||
if (dt > 0.0f) {
|
||||
if (lookJump) {
|
||||
sway.reset();
|
||||
} else {
|
||||
sway.update(deltaYaw, deltaPitch, Math.min(dt, MAX_FRAME_DELTA_TICKS));
|
||||
}
|
||||
driftMain.setTarget(usingMain);
|
||||
driftMain.update(dt);
|
||||
driftOff.setTarget(usingOff);
|
||||
driftOff.update(dt);
|
||||
join.setTarget(true);
|
||||
join.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
/** Полный сброс: следующий кадр считается первым после входа в мир. */
|
||||
public void reset() {
|
||||
sway.reset();
|
||||
driftMain.setInstant(false);
|
||||
driftOff.setInstant(false);
|
||||
join.setInstant(false);
|
||||
lastYaw = Float.NaN;
|
||||
lastPitch = Float.NaN;
|
||||
lastPlayer = null;
|
||||
lastLevel = null;
|
||||
}
|
||||
|
||||
public SwayState sway() {
|
||||
return sway;
|
||||
}
|
||||
|
||||
/** Сглаженный прогресс дрейфа для указанной руки, 0..1. */
|
||||
public float drift(boolean mainHand) {
|
||||
return (mainHand ? driftMain : driftOff).value();
|
||||
}
|
||||
|
||||
/** Прогресс появления при входе в мир, 0..1 (1 — предмет встал на место). */
|
||||
public float joinProgress() {
|
||||
return join.value();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* 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.animation.hand.state;
|
||||
|
||||
/**
|
||||
* Накопитель «отставания» предмета от поворота камеры (view sway).
|
||||
* <p>
|
||||
* Модель: дельты взгляда за кадр (в градусах) накапливаются в отставание
|
||||
* {@code lag}, ограниченное сверху, и экспоненциально затухают к нулю
|
||||
* (RELAX). Отображаемое значение догоняет накопленное отставание
|
||||
* экспонентой (RESPONSE). Всё в градусах, время — в тиках, поэтому
|
||||
* поведение не зависит от частоты кадров.
|
||||
* <p>
|
||||
* Класс чистый: без Minecraft-типов, покрыт юнит-тестами.
|
||||
*/
|
||||
public final class SwayState {
|
||||
|
||||
public static final float MAX_YAW_LAG_DEG = 6.0f;
|
||||
public static final float MAX_PITCH_LAG_DEG = 4.0f;
|
||||
|
||||
/** Скорость догоняния отображаемого отставания, 1/тик. */
|
||||
static final float RESPONSE_PER_TICK = 6.0f;
|
||||
/** Скорость возврата накопленного отставания к нулю, 1/тик. */
|
||||
static final float RELAX_PER_TICK = 0.25f;
|
||||
/** Верхняя граница шага, чтобы пауза/лаг не давали рывок. */
|
||||
static final float MAX_DELTA_TICKS = 5.0f;
|
||||
|
||||
/** Ниже этого всё состояние защёлкивается в точный ноль — конечное время затухания. */
|
||||
private static final float SETTLE_EPSILON = 1.0e-4f;
|
||||
|
||||
private float yawLag; // накопленное отставание по yaw, градусы
|
||||
private float pitchLag; // накопленное отставание по pitch, градусы
|
||||
private float yawSmooth; // отображаемое отставание по yaw, градусы
|
||||
private float pitchSmooth;
|
||||
|
||||
/**
|
||||
* Продвигает состояние на один кадр.
|
||||
*
|
||||
* @param deltaYawDeg изменение yaw камеры за кадр (может быть любым по знаку)
|
||||
* @param deltaPitchDeg изменение pitch камеры за кадр
|
||||
* @param deltaTicks реальное время кадра в тиках; неположительное пропускается
|
||||
*/
|
||||
public void update(float deltaYawDeg, float deltaPitchDeg, float deltaTicks) {
|
||||
float dt = sanitize(deltaTicks);
|
||||
if (dt <= 0.0f) {
|
||||
return;
|
||||
}
|
||||
yawLag = clamp(yawLag + sanitize(deltaYawDeg), -MAX_YAW_LAG_DEG, MAX_YAW_LAG_DEG);
|
||||
pitchLag = clamp(pitchLag + sanitize(deltaPitchDeg), -MAX_PITCH_LAG_DEG, MAX_PITCH_LAG_DEG);
|
||||
|
||||
float follow = 1.0f - (float) Math.exp(-RESPONSE_PER_TICK * dt);
|
||||
yawSmooth += (yawLag - yawSmooth) * follow;
|
||||
pitchSmooth += (pitchLag - pitchSmooth) * follow;
|
||||
|
||||
float relax = (float) Math.exp(-RELAX_PER_TICK * dt);
|
||||
yawLag *= relax;
|
||||
pitchLag *= relax;
|
||||
|
||||
if (Math.abs(yawLag) <= SETTLE_EPSILON && Math.abs(pitchLag) <= SETTLE_EPSILON
|
||||
&& Math.abs(yawSmooth) <= SETTLE_EPSILON && Math.abs(pitchSmooth) <= SETTLE_EPSILON) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Мгновенный сброс: предмет стоит ровно, без хвостов. */
|
||||
public void reset() {
|
||||
yawLag = 0.0f;
|
||||
pitchLag = 0.0f;
|
||||
yawSmooth = 0.0f;
|
||||
pitchSmooth = 0.0f;
|
||||
}
|
||||
|
||||
/** Сглаженное отставание по yaw в градусах. */
|
||||
public float yawLag() {
|
||||
return yawSmooth;
|
||||
}
|
||||
|
||||
/** Сглаженное отставание по pitch в градусах. */
|
||||
public float pitchLag() {
|
||||
return pitchSmooth;
|
||||
}
|
||||
|
||||
/** Обе оси в нуле — считать можно не применять трансформ вовсе. */
|
||||
public boolean isSettled() {
|
||||
return yawLag == 0.0f && pitchLag == 0.0f && yawSmooth == 0.0f && pitchSmooth == 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Приводит разницу углов к диапазону [-180, 180): переход через
|
||||
* границу мира не даёт ложного рывка на 360 градусов.
|
||||
*/
|
||||
public static float wrapDegrees(float degrees) {
|
||||
float d = sanitize(degrees) % 360.0f;
|
||||
if (d >= 180.0f) {
|
||||
d -= 360.0f;
|
||||
}
|
||||
if (d < -180.0f) {
|
||||
d += 360.0f;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
static float sanitize(float v) {
|
||||
return (Float.isNaN(v) || Float.isInfinite(v)) ? 0.0f : v;
|
||||
}
|
||||
|
||||
static float clamp(float v, float min, float max) {
|
||||
if (v < min) return min;
|
||||
if (v > max) return max;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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.block.bed;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.block.BedBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.block.scan.BlockSearchUtil;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public enum BedBlockUtil {
|
||||
;
|
||||
|
||||
public static boolean isBed(BlockState state) {
|
||||
return state != null && state.getBlock() instanceof BedBlock;
|
||||
}
|
||||
|
||||
public static Direction anotherBedPartDirection(BlockState state) {
|
||||
if (!isBed(state)) {
|
||||
return null;
|
||||
}
|
||||
return BedBlock.getConnectedDirection(state);
|
||||
}
|
||||
|
||||
public static BlockPos canonicalBedPos(BlockPos pos, BlockState state) {
|
||||
if (pos == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Direction anotherPartDirection = anotherBedPartDirection(state);
|
||||
if (anotherPartDirection == null) {
|
||||
return pos.immutable();
|
||||
}
|
||||
|
||||
BlockPos otherPart = pos.relative(anotherPartDirection);
|
||||
return pos.asLong() <= otherPart.asLong() ? pos.immutable() : otherPart.immutable();
|
||||
}
|
||||
|
||||
public static List<LayeredBlockPos> searchBedLayer(BlockPos pos, BlockState state, int layers) {
|
||||
if (pos == null || !isBed(state) || layers <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Direction anotherPartDirection = anotherBedPartDirection(state);
|
||||
if (anotherPartDirection == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Direction bedDirection = anotherPartDirection.getOpposite();
|
||||
Direction left;
|
||||
Direction right;
|
||||
if (bedDirection.getAxis() == Direction.Axis.X) {
|
||||
left = Direction.SOUTH;
|
||||
right = Direction.NORTH;
|
||||
} else {
|
||||
left = Direction.WEST;
|
||||
right = Direction.EAST;
|
||||
}
|
||||
|
||||
List<LayeredBlockPos> results = new ArrayList<>();
|
||||
results.addAll(searchLayer(pos, layers, bedDirection, Direction.UP, left, right));
|
||||
results.addAll(searchLayer(pos.relative(anotherPartDirection), layers, anotherPartDirection, Direction.UP, left, right));
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<LayeredBlockPos> searchLayer(BlockPos origin, int layers, Direction... directions) {
|
||||
if (origin == null || layers <= 0 || directions == null || directions.length == 0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<LayeredBlockPos> results = new ArrayList<>();
|
||||
ArrayDeque<LayeredBlockPos> queue = new ArrayDeque<>();
|
||||
Set<BlockPos> visited = new HashSet<>();
|
||||
|
||||
BlockPos start = origin.immutable();
|
||||
queue.add(new LayeredBlockPos(0, start));
|
||||
visited.add(start);
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
LayeredBlockPos next = queue.removeFirst();
|
||||
if (next.layer() > 0) {
|
||||
results.add(next);
|
||||
}
|
||||
|
||||
if (next.layer() >= layers) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Direction direction : directions) {
|
||||
BlockPos candidate = next.pos().relative(direction);
|
||||
if (!visited.add(candidate)) {
|
||||
continue;
|
||||
}
|
||||
queue.addLast(new LayeredBlockPos(next.layer() + 1, candidate.immutable()));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static BedSearchEntry findClosestBed(
|
||||
BlockGetter world,
|
||||
Vec3 center,
|
||||
double range,
|
||||
BiPredicate<BlockPos, BlockState> filter
|
||||
) {
|
||||
if (world == null || center == null || range < 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BlockSearchUtil.searchBlocksInCuboid(world, center, range, (pos, state) ->
|
||||
isBed(state) && (filter == null || filter.test(pos, state)))
|
||||
.stream()
|
||||
.min(Comparator.comparingDouble(entry -> Vec3.atCenterOf(entry.pos()).distanceToSqr(center)))
|
||||
.map(entry -> new BedSearchEntry(entry.pos(), entry.state()))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public record LayeredBlockPos(int layer, BlockPos pos) {
|
||||
}
|
||||
|
||||
public record BedSearchEntry(BlockPos pos, BlockState state) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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.block.bed;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.PlayerInfo;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.component.DyedItemColor;
|
||||
import net.minecraft.world.level.block.BedBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.scores.PlayerTeam;
|
||||
|
||||
public enum BedwarsTeamColorUtil {
|
||||
;
|
||||
|
||||
public static Integer getPlayerTeamRgb(Player player) {
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||
if (!slot.isArmor()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ItemStack stack = player.getItemBySlot(slot);
|
||||
if (stack.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int color = DyedItemColor.getOrDefault(stack, -1);
|
||||
if (color != -1) {
|
||||
return color & 0x00FFFFFF;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Integer getPlayerTeamRgb(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.level == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Player player : mc.level.players()) {
|
||||
String playerName = player.getGameProfile().name();
|
||||
if (playerName != null && playerName.equalsIgnoreCase(name)) {
|
||||
return getPlayerTeamRgb(player);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Integer getPlayerTabRgb(Player player) {
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Integer scoreboardRgb = getScoreboardTeamRgb(player.getTeam());
|
||||
if (scoreboardRgb != null) {
|
||||
return scoreboardRgb;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.getConnection() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerInfo entry = mc.getConnection().getPlayerInfo(player.getUUID());
|
||||
return entry == null ? null : extractTextRgb(entry.getTabListDisplayName());
|
||||
}
|
||||
|
||||
public static Integer getPlayerTabRgb(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mc.level != null) {
|
||||
for (Player player : mc.level.players()) {
|
||||
String playerName = player.getGameProfile().name();
|
||||
if (playerName != null && playerName.equalsIgnoreCase(name)) {
|
||||
Integer scoreboardRgb = getScoreboardTeamRgb(player.getTeam());
|
||||
if (scoreboardRgb != null) {
|
||||
return scoreboardRgb;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mc == null || mc.getConnection() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) {
|
||||
if (entry == null || entry.getProfile() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String entryName = entry.getProfile().name();
|
||||
if (entryName != null && entryName.equalsIgnoreCase(name)) {
|
||||
return extractTextRgb(entry.getTabListDisplayName());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static TeamRelation determineRelation(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return TeamRelation.NONE;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.player == null) {
|
||||
return TeamRelation.NONE;
|
||||
}
|
||||
|
||||
String selfName = mc.player.getGameProfile().name();
|
||||
if (selfName != null && selfName.equalsIgnoreCase(name)) {
|
||||
return TeamRelation.SELF;
|
||||
}
|
||||
|
||||
Integer selfTab = getPlayerTabRgb(mc.player);
|
||||
Integer otherTab = getPlayerTabRgb(name);
|
||||
if (selfTab != null && otherTab != null) {
|
||||
return selfTab.equals(otherTab) ? TeamRelation.SELF : TeamRelation.ENEMY;
|
||||
}
|
||||
|
||||
Integer selfArmor = getPlayerTeamRgb(mc.player);
|
||||
Integer otherArmor = getPlayerTeamRgb(name);
|
||||
if (selfArmor != null && otherArmor != null) {
|
||||
return selfArmor.equals(otherArmor) ? TeamRelation.SELF : TeamRelation.ENEMY;
|
||||
}
|
||||
|
||||
return TeamRelation.NONE;
|
||||
}
|
||||
|
||||
public static Integer getBedTeamRgb(BlockState state) {
|
||||
if (state == null || !(state.getBlock() instanceof BedBlock bedBlock)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bedBlock.getColor().getTextureDiffuseColor() & 0x00FFFFFF;
|
||||
}
|
||||
|
||||
public static int replaceRgb(int argb, int rgb) {
|
||||
return (argb & 0xFF000000) | (rgb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
private static Integer extractTextRgb(Component text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.getStyle() != null && text.getStyle().getColor() != null) {
|
||||
return text.getStyle().getColor().getValue() & 0x00FFFFFF;
|
||||
}
|
||||
|
||||
for (Component sibling : text.getSiblings()) {
|
||||
Integer siblingRgb = extractTextRgb(sibling);
|
||||
if (siblingRgb != null) {
|
||||
return siblingRgb;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer getScoreboardTeamRgb(PlayerTeam team) {
|
||||
if (team == null || team.getColor().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return team.getColor().map(net.minecraft.world.scores.TeamColor::rgb).orElse(null);
|
||||
}
|
||||
|
||||
public enum TeamRelation {
|
||||
SELF,
|
||||
ENEMY,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.block.bed;
|
||||
|
||||
import dev.loki.lovisual.config.values.mode.EnumValue;
|
||||
|
||||
public enum SelfBedMode implements EnumValue.IdProvider {
|
||||
NONE("none"),
|
||||
COLOR("color"),
|
||||
SPAWN_LOCATION("spawn_location"),
|
||||
MANUAL("manual");
|
||||
|
||||
private final String id;
|
||||
|
||||
SelfBedMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* 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.block.bed;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.component.DyedItemColor;
|
||||
import net.minecraft.world.level.block.BedBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
import dev.loki.lovisual.events.impl.network.PacketEvent;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.impl.hudnotifier.HudNotifier;
|
||||
import dev.loki.lovisual.util.block.bed.BedBlockUtil;
|
||||
import dev.loki.lovisual.util.block.bed.SelfBedMode;
|
||||
|
||||
public final class SelfBedTracker {
|
||||
public static final SelfBedTracker INSTANCE = new SelfBedTracker();
|
||||
|
||||
private static final double DEFAULT_MANUAL_TRACK_RANGE = 16.0;
|
||||
private static final double MIN_SPAWN_PACKET_DISTANCE_SQ = 16.0 * 16.0;
|
||||
|
||||
private Vec3 trackedSpawnLocation;
|
||||
private BlockPos trackedManualBed;
|
||||
private ClientLevel lastWorld;
|
||||
|
||||
private SelfBedTracker() {
|
||||
}
|
||||
|
||||
public boolean shouldDefend(
|
||||
SelfBedMode mode,
|
||||
BlockState state,
|
||||
BlockPos pos,
|
||||
LocalPlayer player,
|
||||
double spawnBedDistance
|
||||
) {
|
||||
return mode == SelfBedMode.NONE || isSelfBed(mode, state, pos, player, spawnBedDistance);
|
||||
}
|
||||
|
||||
public boolean isSelfBed(
|
||||
SelfBedMode mode,
|
||||
BlockState state,
|
||||
BlockPos pos,
|
||||
LocalPlayer player,
|
||||
double spawnBedDistance
|
||||
) {
|
||||
if (mode == null || state == null || pos == null || !(state.getBlock() instanceof BedBlock bedBlock)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (mode) {
|
||||
case NONE -> false;
|
||||
case COLOR -> matchesArmorColor(player, bedBlock);
|
||||
case SPAWN_LOCATION -> isNearTrackedSpawn(pos, spawnBedDistance);
|
||||
case MANUAL -> matchesManualBed(state, pos);
|
||||
};
|
||||
}
|
||||
|
||||
public void trackNearestBed(LocalPlayer player) {
|
||||
trackNearestBed(player, DEFAULT_MANUAL_TRACK_RANGE);
|
||||
}
|
||||
|
||||
public void trackNearestBed(LocalPlayer player, double range) {
|
||||
if (player == null || player.level() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
BedBlockUtil.BedSearchEntry bed = BedBlockUtil.findClosestBed(
|
||||
player.level(),
|
||||
player.getEyePosition(),
|
||||
range,
|
||||
(pos, state) -> true
|
||||
);
|
||||
|
||||
if (bed == null) {
|
||||
HudNotifier.pushMessage("SelfBed: cannot find any bed around you. Get closer to your bed.", HudNotifier.NotifyType.NO);
|
||||
return;
|
||||
}
|
||||
|
||||
trackedManualBed = bed.pos().immutable();
|
||||
lastWorld = Minecraft.getInstance().level;
|
||||
HudNotifier.pushMessage(
|
||||
"SelfBed: tracked bed position " + trackedManualBed.getX() + " " + trackedManualBed.getY() + " " + trackedManualBed.getZ(),
|
||||
HudNotifier.NotifyType.YES
|
||||
);
|
||||
}
|
||||
|
||||
public void untrackManualBed() {
|
||||
if (trackedManualBed == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockPos pos = trackedManualBed;
|
||||
trackedManualBed = null;
|
||||
HudNotifier.pushMessage(
|
||||
"SelfBed: bed position " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " has been untracked.",
|
||||
HudNotifier.NotifyType.INFO
|
||||
);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
trackedSpawnLocation = null;
|
||||
trackedManualBed = null;
|
||||
lastWorld = null;
|
||||
}
|
||||
|
||||
public Vec3 getTrackedSpawnLocation() {
|
||||
return trackedSpawnLocation;
|
||||
}
|
||||
|
||||
public BlockPos getTrackedManualBed() {
|
||||
return trackedManualBed;
|
||||
}
|
||||
|
||||
@EventHandler(priority = 1000)
|
||||
private void onPacketReceive(PacketEvent.Receive event) {
|
||||
if (!(event.getPacket() instanceof ClientboundPlayerPositionPacket packet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Vec3 position = packet.change().position();
|
||||
if (player.position().distanceToSqr(position) > MIN_SPAWN_PACKET_DISTANCE_SQ) {
|
||||
trackedSpawnLocation = position;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onGameTick(GameTickEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ClientLevel world = mc.level;
|
||||
|
||||
if (world != lastWorld) {
|
||||
if (trackedManualBed != null && lastWorld != null) {
|
||||
BlockPos pos = trackedManualBed;
|
||||
trackedManualBed = null;
|
||||
HudNotifier.pushMessage(
|
||||
"SelfBed: bed position " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " has been untracked due to world change.",
|
||||
HudNotifier.NotifyType.INFO
|
||||
);
|
||||
}
|
||||
lastWorld = world;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesArmorColor(LocalPlayer player, BedBlock bedBlock) {
|
||||
Integer armorColor = getArmorColor(player);
|
||||
return armorColor != null && armorColor == bedBlock.getColor().getTextureDiffuseColor();
|
||||
}
|
||||
|
||||
private boolean isNearTrackedSpawn(BlockPos pos, double distance) {
|
||||
if (trackedSpawnLocation == null || distance < 0.0) {
|
||||
return false;
|
||||
}
|
||||
return trackedSpawnLocation.distanceToSqr(pos.getX(), pos.getY(), pos.getZ()) <= distance * distance;
|
||||
}
|
||||
|
||||
private boolean matchesManualBed(BlockState state, BlockPos pos) {
|
||||
if (trackedManualBed == null) {
|
||||
return false;
|
||||
}
|
||||
if (trackedManualBed.equals(pos)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Direction direction = BedBlockUtil.anotherBedPartDirection(state);
|
||||
return direction != null && pos.relative(direction).equals(trackedManualBed);
|
||||
}
|
||||
|
||||
private Integer getArmorColor(LocalPlayer player) {
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||
if (!slot.isArmor()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ItemStack stack = player.getItemBySlot(slot);
|
||||
if (stack.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int color = DyedItemColor.getOrDefault(stack, -1);
|
||||
if (color != -1) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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.block.render;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.util.block.render.BlockOutlineShapeMode;
|
||||
|
||||
public enum BlockOutlineRenderer {
|
||||
;
|
||||
|
||||
public static void render(Renderer3D renderer,
|
||||
ClientLevel world,
|
||||
BlockPos pos,
|
||||
BlockState state,
|
||||
BlockOutlineShapeMode mode,
|
||||
int argb) {
|
||||
if (renderer == null || world == null || pos == null || state == null || state.isAir()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == BlockOutlineShapeMode.VOXEL_SHAPE) {
|
||||
renderVoxelShape(renderer, world, pos, state, argb);
|
||||
} else {
|
||||
renderBox(renderer, new AABB(pos), argb);
|
||||
}
|
||||
}
|
||||
|
||||
public static void renderBox(Renderer3D renderer, AABB box, int argb) {
|
||||
if (renderer == null || box == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int a = alpha(argb);
|
||||
int r = red(argb);
|
||||
int g = green(argb);
|
||||
int b = blue(argb);
|
||||
|
||||
double minX = box.minX;
|
||||
double minY = box.minY;
|
||||
double minZ = box.minZ;
|
||||
double maxX = box.maxX;
|
||||
double maxY = box.maxY;
|
||||
double maxZ = box.maxZ;
|
||||
|
||||
line(renderer, minX, minY, minZ, maxX, minY, minZ, r, g, b, a);
|
||||
line(renderer, maxX, minY, minZ, maxX, minY, maxZ, r, g, b, a);
|
||||
line(renderer, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
|
||||
line(renderer, minX, minY, maxZ, minX, minY, minZ, r, g, b, a);
|
||||
|
||||
line(renderer, minX, maxY, minZ, maxX, maxY, minZ, r, g, b, a);
|
||||
line(renderer, maxX, maxY, minZ, maxX, maxY, maxZ, r, g, b, a);
|
||||
line(renderer, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
|
||||
line(renderer, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
|
||||
|
||||
line(renderer, minX, minY, minZ, minX, maxY, minZ, r, g, b, a);
|
||||
line(renderer, maxX, minY, minZ, maxX, maxY, minZ, r, g, b, a);
|
||||
line(renderer, maxX, minY, maxZ, maxX, maxY, maxZ, r, g, b, a);
|
||||
line(renderer, minX, minY, maxZ, minX, maxY, maxZ, r, g, b, a);
|
||||
}
|
||||
|
||||
private static void renderVoxelShape(Renderer3D renderer,
|
||||
ClientLevel world,
|
||||
BlockPos pos,
|
||||
BlockState state,
|
||||
int argb) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Entity cameraEntity = mc != null ? mc.getCameraEntity() : null;
|
||||
CollisionContext context = cameraEntity != null ? CollisionContext.of(cameraEntity) : CollisionContext.empty();
|
||||
VoxelShape shape = state.getShape(world, pos, context);
|
||||
if (shape.isEmpty()) {
|
||||
renderBox(renderer, new AABB(pos), argb);
|
||||
return;
|
||||
}
|
||||
|
||||
int a = alpha(argb);
|
||||
int r = red(argb);
|
||||
int g = green(argb);
|
||||
int b = blue(argb);
|
||||
shape.move(pos.getX(), pos.getY(), pos.getZ()).forAllEdges((x1, y1, z1, x2, y2, z2) ->
|
||||
line(renderer, x1, y1, z1, x2, y2, z2, r, g, b, a)
|
||||
);
|
||||
}
|
||||
|
||||
private static void line(Renderer3D renderer,
|
||||
double x1, double y1, double z1,
|
||||
double x2, double y2, double z2,
|
||||
int r, int g, int b, int a) {
|
||||
renderer.line(x1, y1, z1, x2, y2, z2, r, g, b, a);
|
||||
}
|
||||
|
||||
private static int red(int argb) {
|
||||
return (argb >>> 16) & 255;
|
||||
}
|
||||
|
||||
private static int green(int argb) {
|
||||
return (argb >>> 8) & 255;
|
||||
}
|
||||
|
||||
private static int blue(int argb) {
|
||||
return argb & 255;
|
||||
}
|
||||
|
||||
private static int alpha(int argb) {
|
||||
return (argb >>> 24) & 255;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.block.render;
|
||||
|
||||
import dev.loki.lovisual.config.values.mode.EnumValue;
|
||||
|
||||
public enum BlockOutlineShapeMode implements EnumValue.IdProvider {
|
||||
FULL_BLOCK("full_block"),
|
||||
VOXEL_SHAPE("voxel_shape");
|
||||
|
||||
private final String id;
|
||||
|
||||
BlockOutlineShapeMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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.block.render;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public enum RenderSectionBlockScanner {
|
||||
;
|
||||
private static final ConcurrentHashMap<Long, BlockState> BLOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
public static void recordBlock(BlockPos pos, BlockState state) {
|
||||
if (pos == null || state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
long key = pos.asLong();
|
||||
if (state.isAir()) {
|
||||
BLOCKS.remove(key);
|
||||
} else {
|
||||
BLOCKS.put(key, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
BLOCKS.clear();
|
||||
}
|
||||
|
||||
public static BlockState getCachedState(BlockPos pos) {
|
||||
return pos == null ? null : BLOCKS.get(pos.asLong());
|
||||
}
|
||||
|
||||
public record ScanResult(BlockPos pos, BlockState state, boolean visible) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* 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.block.render;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Main-thread, budgeted scanner for already loaded client world chunk sections.
|
||||
*
|
||||
* <p>This is intentionally not a worker thread and does not request chunk rebuilds.
|
||||
* Sodium/renderer observations can still feed caches opportunistically, while this
|
||||
* scanner provides deterministic catch-up for enable, setting changes and newly
|
||||
* reachable sections.</p>
|
||||
*/
|
||||
public final class WorldSectionBlockScanner {
|
||||
private final ArrayDeque<Long> queue = new ArrayDeque<>();
|
||||
private final LongOpenHashSet queuedSections = new LongOpenHashSet();
|
||||
private final LongOpenHashSet scannedSections = new LongOpenHashSet();
|
||||
|
||||
private static boolean scanSection(
|
||||
ClientLevel world,
|
||||
long sectionKey,
|
||||
Predicate<BlockState> targetPredicate,
|
||||
ResultConsumer consumer
|
||||
) {
|
||||
int sectionX = SectionPos.x(sectionKey);
|
||||
int sectionY = SectionPos.y(sectionKey);
|
||||
int sectionZ = SectionPos.z(sectionKey);
|
||||
|
||||
ChunkAccess chunk = world.getChunk(sectionX, sectionZ, ChunkStatus.FULL, false);
|
||||
if (chunk == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int sectionIndex = world.getSectionIndexFromSectionY(sectionY);
|
||||
LevelChunkSection[] sections = chunk.getSections();
|
||||
if (sectionIndex < 0 || sectionIndex >= sections.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
LevelChunkSection section = sections[sectionIndex];
|
||||
if (section == null || section.hasOnlyAir() || !section.maybeHas(targetPredicate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int baseX = SectionPos.sectionToBlockCoord(sectionX);
|
||||
int baseY = SectionPos.sectionToBlockCoord(sectionY);
|
||||
int baseZ = SectionPos.sectionToBlockCoord(sectionZ);
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int localY = 0; localY < 16; localY++) {
|
||||
int y = baseY + localY;
|
||||
if (!world.isInsideBuildHeight(y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int z = baseZ + localZ;
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
BlockState state = section.getBlockState(localX, localY, localZ);
|
||||
if (!targetPredicate.test(state)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mutable.set(baseX + localX, y, z);
|
||||
consumer.accept(mutable.immutable(), state);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
queue.clear();
|
||||
queuedSections.clear();
|
||||
scannedSections.clear();
|
||||
}
|
||||
|
||||
public void forget(BlockPos pos) {
|
||||
if (pos == null) {
|
||||
return;
|
||||
}
|
||||
scannedSections.remove(SectionPos.asLong(pos));
|
||||
}
|
||||
|
||||
public void enqueueAround(Minecraft client, int horizontalBlockRadius, int minY, int maxY) {
|
||||
if (client == null || client.level == null || client.player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClientLevel world = client.level;
|
||||
int radius = Math.max(1, horizontalBlockRadius);
|
||||
int chunkRadius = Math.max(1, (radius + 15) >> 4);
|
||||
int centerChunkX = SectionPos.blockToSectionCoord(client.player.getBlockX());
|
||||
int centerChunkZ = SectionPos.blockToSectionCoord(client.player.getBlockZ());
|
||||
|
||||
int clampedMinY = Math.max(world.getMinY(), Math.min(minY, maxY));
|
||||
int clampedMaxY = Math.min(world.getMaxY(), Math.max(minY, maxY));
|
||||
if (clampedMinY > clampedMaxY) {
|
||||
return;
|
||||
}
|
||||
|
||||
int minSectionY = SectionPos.blockToSectionCoord(clampedMinY);
|
||||
int maxSectionY = SectionPos.blockToSectionCoord(clampedMaxY);
|
||||
int centerSectionY = SectionPos.blockToSectionCoord(client.player.getBlockY());
|
||||
|
||||
for (int ring = 0; ring <= chunkRadius; ring++) {
|
||||
for (int dx = -ring; dx <= ring; dx++) {
|
||||
enqueueColumnEdge(centerChunkX + dx, centerChunkZ - ring, minSectionY, maxSectionY, centerSectionY);
|
||||
if (ring != 0) {
|
||||
enqueueColumnEdge(centerChunkX + dx, centerChunkZ + ring, minSectionY, maxSectionY, centerSectionY);
|
||||
}
|
||||
}
|
||||
|
||||
for (int dz = -ring + 1; dz <= ring - 1; dz++) {
|
||||
enqueueColumnEdge(centerChunkX - ring, centerChunkZ + dz, minSectionY, maxSectionY, centerSectionY);
|
||||
if (ring != 0) {
|
||||
enqueueColumnEdge(centerChunkX + ring, centerChunkZ + dz, minSectionY, maxSectionY, centerSectionY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int drain(
|
||||
Minecraft client,
|
||||
int maxSections,
|
||||
Predicate<BlockState> targetPredicate,
|
||||
ResultConsumer consumer
|
||||
) {
|
||||
if (client == null || client.level == null || maxSections <= 0 || targetPredicate == null || consumer == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int processed = 0;
|
||||
int attempted = 0;
|
||||
int maxAttempts = Math.max(maxSections, maxSections * 8);
|
||||
while (processed < maxSections && attempted < maxAttempts && !queue.isEmpty()) {
|
||||
attempted++;
|
||||
long sectionKey = queue.removeFirst();
|
||||
queuedSections.remove(sectionKey);
|
||||
|
||||
if (scanSection(client.level, sectionKey, targetPredicate, consumer)) {
|
||||
scannedSections.add(sectionKey);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
private void enqueueColumnEdge(int chunkX, int chunkZ, int minSectionY, int maxSectionY, int centerSectionY) {
|
||||
int center = Math.max(minSectionY, Math.min(maxSectionY, centerSectionY));
|
||||
enqueueSection(chunkX, center, chunkZ);
|
||||
|
||||
int maxOffset = Math.max(center - minSectionY, maxSectionY - center);
|
||||
for (int offset = 1; offset <= maxOffset; offset++) {
|
||||
int up = center + offset;
|
||||
if (up <= maxSectionY) {
|
||||
enqueueSection(chunkX, up, chunkZ);
|
||||
}
|
||||
|
||||
int down = center - offset;
|
||||
if (down >= minSectionY) {
|
||||
enqueueSection(chunkX, down, chunkZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enqueueSection(int sectionX, int sectionY, int sectionZ) {
|
||||
long key = SectionPos.asLong(sectionX, sectionY, sectionZ);
|
||||
if (scannedSections.contains(key) || !queuedSections.add(key)) {
|
||||
return;
|
||||
}
|
||||
queue.addLast(key);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ResultConsumer {
|
||||
void accept(BlockPos pos, BlockState state);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* 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.block.scan;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.protocol.game.ServerboundSwingPacket;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Predicate;
|
||||
import dev.loki.lovisual.util.block.render.RenderSectionBlockScanner;
|
||||
|
||||
public final class BlockDeobfuscationScanner {
|
||||
private final ArrayList<BlockPos> queue = new ArrayList<>();
|
||||
private final ArrayList<CheckedBlock> checked = new ArrayList<>();
|
||||
private final Set<Long> discovered = new HashSet<>();
|
||||
private final Map<Long, BlockState> positiveStates = new HashMap<>();
|
||||
private AABB area;
|
||||
private int delayTicks;
|
||||
private int done;
|
||||
private int all;
|
||||
private BlockPos displayBlock;
|
||||
|
||||
private static AABB areaOf(Minecraft client, int radius, int up, int down) {
|
||||
int r = Math.max(1, radius);
|
||||
int u = Math.max(1, up);
|
||||
int d = Math.max(1, down);
|
||||
return new AABB(
|
||||
client.player.getX() - r,
|
||||
client.player.getY() - d,
|
||||
client.player.getZ() - r,
|
||||
client.player.getX() + r,
|
||||
client.player.getY() + u,
|
||||
client.player.getZ() + r
|
||||
);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
queue.clear();
|
||||
checked.clear();
|
||||
discovered.clear();
|
||||
positiveStates.clear();
|
||||
area = null;
|
||||
delayTicks = 0;
|
||||
done = 0;
|
||||
all = 0;
|
||||
displayBlock = null;
|
||||
}
|
||||
|
||||
public void tick(
|
||||
Minecraft client,
|
||||
int delay,
|
||||
int radius,
|
||||
int up,
|
||||
int down,
|
||||
boolean fast
|
||||
) {
|
||||
if (client == null || client.level == null || client.player == null || client.gameMode == null) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
AABB currentArea = areaOf(client, radius, up, down);
|
||||
if (area == null || !area.intersects(currentArea)) {
|
||||
rebuildQueue(client, currentArea, radius, up, down, fast);
|
||||
}
|
||||
|
||||
checked.removeIf(block -> {
|
||||
if (block.tickAndIsReady()) {
|
||||
discovered.add(block.pos().asLong());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (queue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (++delayTicks < Math.max(1, delay)) {
|
||||
return;
|
||||
}
|
||||
delayTicks = 0;
|
||||
|
||||
int index = queue.size() <= 1 ? 0 : ThreadLocalRandom.current().nextInt(queue.size());
|
||||
BlockPos pos = queue.remove(index);
|
||||
displayBlock = pos;
|
||||
|
||||
client.gameMode.startDestroyBlock(pos, client.player.getDirection());
|
||||
client.gameMode.stopDestroyBlock();
|
||||
if (client.getConnection() != null) {
|
||||
client.getConnection().send(new ServerboundSwingPacket(InteractionHand.MAIN_HAND));
|
||||
}
|
||||
|
||||
checked.add(new CheckedBlock(pos));
|
||||
done++;
|
||||
}
|
||||
|
||||
public void acceptBlockUpdate(BlockPos pos, BlockState state, Predicate<BlockState> targetPredicate) {
|
||||
if (pos == null || state == null || targetPredicate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetPredicate.test(state)) {
|
||||
long key = pos.asLong();
|
||||
discovered.add(key);
|
||||
positiveStates.put(key, state);
|
||||
RenderSectionBlockScanner.recordBlock(pos, state);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasPositiveState(BlockPos pos) {
|
||||
return pos != null && positiveStates.containsKey(pos.asLong());
|
||||
}
|
||||
|
||||
public BlockState getPositiveState(BlockPos pos) {
|
||||
return pos == null ? null : positiveStates.get(pos.asLong());
|
||||
}
|
||||
|
||||
public List<RenderSectionBlockScanner.ScanResult> snapshot(
|
||||
Minecraft client,
|
||||
Predicate<BlockState> targetPredicate,
|
||||
Predicate<BlockPos> visibilityPredicate,
|
||||
int maxTargets
|
||||
) {
|
||||
if (client == null || client.level == null || maxTargets <= 0 || targetPredicate == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<RenderSectionBlockScanner.ScanResult> results = new ArrayList<>(Math.min(maxTargets, discovered.size()));
|
||||
for (long key : discovered) {
|
||||
if (results.size() >= maxTargets) {
|
||||
break;
|
||||
}
|
||||
|
||||
BlockState state = positiveStates.get(key);
|
||||
BlockPos pos = BlockPos.of(key);
|
||||
if (state == null) {
|
||||
state = RenderSectionBlockScanner.getCachedState(pos);
|
||||
}
|
||||
if (state == null) {
|
||||
state = client.level.getBlockState(pos);
|
||||
}
|
||||
if (!targetPredicate.test(state)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean visible = visibilityPredicate != null && visibilityPredicate.test(pos);
|
||||
results.add(new RenderSectionBlockScanner.ScanResult(pos, state, visible));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public int done() {
|
||||
return done;
|
||||
}
|
||||
|
||||
public int all() {
|
||||
return all;
|
||||
}
|
||||
|
||||
public BlockPos displayBlock() {
|
||||
return displayBlock;
|
||||
}
|
||||
|
||||
private void rebuildQueue(Minecraft client, AABB newArea, int radius, int up, int down, boolean fast) {
|
||||
queue.clear();
|
||||
checked.clear();
|
||||
area = newArea;
|
||||
done = 0;
|
||||
displayBlock = null;
|
||||
|
||||
int clampedRadius = Math.max(1, radius);
|
||||
int clampedUp = Math.max(1, up);
|
||||
int clampedDown = Math.max(1, down);
|
||||
|
||||
int px = client.player.getBlockX();
|
||||
int py = client.player.getBlockY();
|
||||
int pz = client.player.getBlockZ();
|
||||
int minY = client.level.getMinY();
|
||||
int maxY = client.level.getMinY() + client.level.getHeight() - 1;
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int x = px - clampedRadius; x <= px + clampedRadius; x++) {
|
||||
for (int y = Math.max(minY, py - clampedDown); y <= Math.min(maxY, py + clampedUp); y++) {
|
||||
for (int z = pz - clampedRadius; z <= pz + clampedRadius; z++) {
|
||||
if (fast && (x % 2 == 0 || y % 2 == 0 || z % 2 == 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mutable.set(x, y, z);
|
||||
BlockState state = client.level.getBlockState(mutable);
|
||||
if (!state.isAir()) {
|
||||
queue.add(mutable.immutable());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all = queue.size();
|
||||
}
|
||||
|
||||
private static final class CheckedBlock {
|
||||
private final BlockPos pos;
|
||||
private int age;
|
||||
|
||||
private CheckedBlock(BlockPos pos) {
|
||||
this.pos = pos;
|
||||
}
|
||||
|
||||
private BlockPos pos() {
|
||||
return pos;
|
||||
}
|
||||
|
||||
private boolean tickAndIsReady() {
|
||||
age++;
|
||||
return age > 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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.block.scan;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.config.values.collection.ItemIdSetValue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class BlockScanContext {
|
||||
public static final Direction[] DIRECTIONS = new Direction[]{
|
||||
Direction.UP,
|
||||
Direction.DOWN,
|
||||
Direction.NORTH,
|
||||
Direction.SOUTH,
|
||||
Direction.EAST,
|
||||
Direction.WEST
|
||||
};
|
||||
|
||||
private final Minecraft client;
|
||||
private final ItemIdSetValue targetBlocks;
|
||||
|
||||
private volatile Set<Identifier> targetIdCache = Set.of();
|
||||
private volatile Set<Block> targetBlockCache = Set.of();
|
||||
private volatile int targetIdCacheHash;
|
||||
|
||||
public BlockScanContext(Minecraft client, ItemIdSetValue targetBlocks) {
|
||||
this.client = client;
|
||||
this.targetBlocks = targetBlocks;
|
||||
}
|
||||
|
||||
private static Set<Identifier> parseIds(Set<String> rawIds) {
|
||||
HashSet<Identifier> ids = new HashSet<>(rawIds.size());
|
||||
for (String raw : rawIds) {
|
||||
Identifier id = Identifier.tryParse(raw);
|
||||
if (id != null) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static Set<Block> parseBlocks(Set<Identifier> ids) {
|
||||
HashSet<Block> blocks = new HashSet<>(ids.size());
|
||||
for (Identifier id : ids) {
|
||||
blocks.add(BuiltInRegistries.BLOCK.getValue(id));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (targetBlocks != null) {
|
||||
Set<String> rawTargets = targetBlocks.get();
|
||||
int targetHash = rawTargets.hashCode();
|
||||
if (targetHash != targetIdCacheHash) {
|
||||
targetIdCache = parseIds(rawTargets);
|
||||
targetBlockCache = parseBlocks(targetIdCache);
|
||||
targetIdCacheHash = targetHash;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public boolean isConfiguredTarget(BlockState state) {
|
||||
if (state == null || state.isAir() || targetBlocks == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetBlockCache.contains(state.getBlock());
|
||||
}
|
||||
|
||||
public Set<Block> targetBlocksSnapshot() {
|
||||
return targetBlockCache;
|
||||
}
|
||||
|
||||
public boolean isPassable(BlockPos pos) {
|
||||
if (pos == null || client.level == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BlockState state = client.level.getBlockState(pos);
|
||||
if (state.isAir()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state.getCollisionShape(client.level, pos).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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.block.scan;
|
||||
|
||||
import dev.loki.lovisual.config.values.mode.EnumValue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public enum BlockScanMode implements EnumValue.IdProvider, EnumValue.AliasProvider {
|
||||
LOS("los", "los_unlocked"),
|
||||
UNRESTRICTED("unrestricted", "aggressive");
|
||||
|
||||
private final String id;
|
||||
private final List<String> aliases;
|
||||
|
||||
BlockScanMode(String id, String... aliases) {
|
||||
this.id = id;
|
||||
this.aliases = List.of(aliases);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> aliases() {
|
||||
return aliases;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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.block.scan;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
public enum BlockSearchUtil {
|
||||
;
|
||||
|
||||
public static List<BlockSearchEntry> searchBlocksInCuboid(
|
||||
BlockGetter world,
|
||||
Vec3 center,
|
||||
double range,
|
||||
BiPredicate<BlockPos, BlockState> filter
|
||||
) {
|
||||
return searchBlocksInCuboid(world, center, range, range, filter);
|
||||
}
|
||||
|
||||
public static List<BlockSearchEntry> searchBlocksInCuboid(
|
||||
BlockGetter world,
|
||||
Vec3 center,
|
||||
double rangeXZ,
|
||||
double rangeY,
|
||||
BiPredicate<BlockPos, BlockState> filter
|
||||
) {
|
||||
if (world == null || center == null || rangeXZ < 0.0 || rangeY < 0.0) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
int minX = Mth.floor(center.x - rangeXZ);
|
||||
int minY = Mth.floor(center.y - rangeY);
|
||||
int minZ = Mth.floor(center.z - rangeXZ);
|
||||
int maxX = Mth.floor(center.x + rangeXZ);
|
||||
int maxY = Mth.floor(center.y + rangeY);
|
||||
int maxZ = Mth.floor(center.z + rangeXZ);
|
||||
|
||||
List<BlockSearchEntry> results = new ArrayList<>();
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
mutable.set(x, y, z);
|
||||
BlockPos pos = mutable.immutable();
|
||||
BlockState state = world.getBlockState(pos);
|
||||
if (filter != null && !filter.test(pos, state)) {
|
||||
continue;
|
||||
}
|
||||
results.add(new BlockSearchEntry(pos, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public record BlockSearchEntry(BlockPos pos, BlockState state) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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.click;
|
||||
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundSwingPacket;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.Items;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.network.PacketEvent;
|
||||
import dev.loki.lovisual.util.combat.rotation.LethalStrikeCalculator;
|
||||
|
||||
/**
|
||||
* Shared modern attack gate logic.
|
||||
* Tracks outgoing swing/slot packets and exposes a single readiness check.
|
||||
*/
|
||||
public final class AttackPressing {
|
||||
|
||||
public static final AttackPressing INSTANCE = new AttackPressing();
|
||||
private static final float READY_COOLDOWN_PROGRESS = 0.9F;
|
||||
private static final long MINIMUM_COOLDOWN_MS = 500L;
|
||||
private long lastClickTime = System.currentTimeMillis();
|
||||
private int clickCount = 0;
|
||||
|
||||
private AttackPressing() {
|
||||
}
|
||||
|
||||
public boolean isCooldownComplete(LocalPlayer player, boolean legacyProtocol, int ticks) {
|
||||
return isCooldownComplete(player, null, legacyProtocol, ticks);
|
||||
}
|
||||
|
||||
public boolean isCooldownComplete(LocalPlayer player, LivingEntity target, boolean legacyProtocol, int ticks) {
|
||||
if (player == null) return false;
|
||||
if (legacyProtocol) return true;
|
||||
|
||||
int safeTicks = Math.max(0, ticks);
|
||||
boolean isMace = player.getMainHandItem().is(Items.MACE);
|
||||
boolean cooldownReady = isMace
|
||||
|| player.getAttackStrengthScale(safeTicks) > READY_COOLDOWN_PROGRESS
|
||||
|| LethalStrikeCalculator.canKillNow(player, target, safeTicks);
|
||||
boolean minimumDelayPassed = lastClickPassed() >= MINIMUM_COOLDOWN_MS;
|
||||
return cooldownReady && minimumDelayPassed;
|
||||
}
|
||||
|
||||
public boolean willCooldownComplete(LocalPlayer player, boolean legacyProtocol, int ticks) {
|
||||
return willCooldownComplete(player, null, legacyProtocol, ticks);
|
||||
}
|
||||
|
||||
public boolean willCooldownComplete(LocalPlayer player, LivingEntity target, boolean legacyProtocol, int ticks) {
|
||||
if (player == null) return false;
|
||||
if (legacyProtocol) return true;
|
||||
|
||||
int safeTicks = Math.max(0, ticks);
|
||||
boolean isMace = player.getMainHandItem().is(Items.MACE);
|
||||
boolean cooldownReady = isMace
|
||||
|| player.getAttackStrengthScale(safeTicks) > READY_COOLDOWN_PROGRESS
|
||||
|| LethalStrikeCalculator.canKillNow(player, target, safeTicks);
|
||||
boolean minimumDelayPassed = lastClickPassed() + safeTicks * 50L >= MINIMUM_COOLDOWN_MS;
|
||||
return cooldownReady && minimumDelayPassed;
|
||||
}
|
||||
|
||||
public long lastClickPassed() {
|
||||
return System.currentTimeMillis() - lastClickTime;
|
||||
}
|
||||
|
||||
public int clickCount() {
|
||||
return clickCount;
|
||||
}
|
||||
|
||||
public void recalculate() {
|
||||
lastClickTime = System.currentTimeMillis();
|
||||
clickCount++;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketSend(PacketEvent.Send event) {
|
||||
if (event.getPacket() instanceof ServerboundSwingPacket
|
||||
|| event.getPacket() instanceof ServerboundSetCarriedItemPacket) {
|
||||
recalculate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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.click;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanMapValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Human-like click behavior helper (misses / pauses).
|
||||
*/
|
||||
public final class ClickHumanizer {
|
||||
|
||||
private final BooleanMapValue toggles;
|
||||
private final NumberValue<Integer> missRate;
|
||||
private final NumberValue<Integer> swingRate;
|
||||
private final NumberValue<Integer> pauseRate;
|
||||
private final NumberValue<Integer> pauseMin;
|
||||
private final NumberValue<Integer> pauseMax;
|
||||
private final String toggleMiss;
|
||||
private final String toggleMissSwing;
|
||||
private final String togglePause;
|
||||
private int pauseTicks = 0;
|
||||
|
||||
public ClickHumanizer(BooleanMapValue toggles,
|
||||
NumberValue<Integer> missRate,
|
||||
NumberValue<Integer> swingRate,
|
||||
NumberValue<Integer> pauseRate,
|
||||
NumberValue<Integer> pauseMin,
|
||||
NumberValue<Integer> pauseMax,
|
||||
String toggleMiss,
|
||||
String toggleMissSwing,
|
||||
String togglePause) {
|
||||
this.toggles = toggles;
|
||||
this.missRate = missRate;
|
||||
this.swingRate = swingRate;
|
||||
this.pauseRate = pauseRate;
|
||||
this.pauseMin = pauseMin;
|
||||
this.pauseMax = pauseMax;
|
||||
this.toggleMiss = toggleMiss;
|
||||
this.toggleMissSwing = toggleMissSwing;
|
||||
this.togglePause = togglePause;
|
||||
}
|
||||
|
||||
private static boolean roll(int rate) {
|
||||
if (rate <= 0) return false;
|
||||
if (rate >= 100) return true;
|
||||
return ThreadLocalRandom.current().nextInt(1, 101) <= rate;
|
||||
}
|
||||
|
||||
private static int rand(int min, int max) {
|
||||
if (max < min) max = min;
|
||||
return ThreadLocalRandom.current().nextInt(min, max + 1);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
pauseTicks = 0;
|
||||
}
|
||||
|
||||
public Decision decide() {
|
||||
if (pauseTicks > 0) {
|
||||
pauseTicks--;
|
||||
return Decision.PAUSE;
|
||||
}
|
||||
|
||||
if (toggles.get(togglePause) && roll(pauseRate.get())) {
|
||||
int min = pauseMin.get();
|
||||
int max = pauseMax.get();
|
||||
if (max < min) max = min;
|
||||
pauseTicks = rand(min, max);
|
||||
return Decision.PAUSE;
|
||||
}
|
||||
|
||||
if (toggles.get(toggleMissSwing) && roll(swingRate.get())) {
|
||||
return Decision.MISS_SWING;
|
||||
}
|
||||
|
||||
if (toggles.get(toggleMiss) && roll(missRate.get())) {
|
||||
return Decision.MISS;
|
||||
}
|
||||
|
||||
return Decision.CLICK;
|
||||
}
|
||||
|
||||
public enum Decision {
|
||||
CLICK,
|
||||
MISS,
|
||||
MISS_SWING,
|
||||
PAUSE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* 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.click;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public final class ClickScheduler {
|
||||
|
||||
private static final int CYCLE_LENGTH = 20;
|
||||
private static final int ITERATIONS = 2;
|
||||
|
||||
private final RollingClickArray clickArray = new RollingClickArray(CYCLE_LENGTH, ITERATIONS);
|
||||
private int minCps;
|
||||
private int maxCps;
|
||||
|
||||
public ClickScheduler(int minCps, int maxCps) {
|
||||
setCps(minCps, maxCps);
|
||||
fill();
|
||||
}
|
||||
|
||||
public void setCps(int minCps, int maxCps) {
|
||||
this.minCps = Math.max(1, minCps);
|
||||
this.maxCps = Math.max(this.minCps, maxCps);
|
||||
fill();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
fill();
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
if (clickArray.advance()) {
|
||||
clickArray.push(generateCycle());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean shouldClick() {
|
||||
return willClickAt(0);
|
||||
}
|
||||
|
||||
public int getClicksAt(int tick) {
|
||||
return getClickAmount(Math.max(0, tick));
|
||||
}
|
||||
|
||||
public boolean willClickAt(int tick) {
|
||||
return getClicksAt(tick) > 0;
|
||||
}
|
||||
|
||||
public boolean willClickInTicks(int ticks) {
|
||||
int clamped = Math.max(0, ticks);
|
||||
for (int i = 0; i <= clamped; i++) {
|
||||
if (willClickAt(i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int getClickAmount(int tick) {
|
||||
return clickArray.get(tick);
|
||||
}
|
||||
|
||||
private void fill() {
|
||||
clickArray.clear();
|
||||
int[] cycleArray = new int[CYCLE_LENGTH];
|
||||
for (int i = 0; i < ITERATIONS; i++) {
|
||||
Arrays.fill(cycleArray, 0);
|
||||
fillStabilizedPattern(cycleArray);
|
||||
clickArray.push(cycleArray);
|
||||
clickArray.advance(CYCLE_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
private int[] generateCycle() {
|
||||
int[] cycleArray = new int[CYCLE_LENGTH];
|
||||
fillStabilizedPattern(cycleArray);
|
||||
return cycleArray;
|
||||
}
|
||||
|
||||
private void fillStabilizedPattern(int[] cycleArray) {
|
||||
int clicks = randomCps();
|
||||
int interval = clicks > 0 ? cycleArray.length / clicks : 0;
|
||||
int remainder = clicks > 0 ? cycleArray.length % clicks : 0;
|
||||
int currentIndex = 0;
|
||||
|
||||
for (int i = 0; i < clicks; i++) {
|
||||
cycleArray[currentIndex % cycleArray.length]++;
|
||||
currentIndex += Math.max(interval, 1);
|
||||
if (remainder > 0) {
|
||||
currentIndex++;
|
||||
remainder--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int randomCps() {
|
||||
if (minCps >= maxCps) return minCps;
|
||||
return ThreadLocalRandom.current().nextInt(minCps, maxCps + 1);
|
||||
}
|
||||
|
||||
private static final class RollingClickArray {
|
||||
private final int cycleLength;
|
||||
private final int[] array;
|
||||
private int head;
|
||||
|
||||
private RollingClickArray(int cycleLength, int iterations) {
|
||||
this.cycleLength = cycleLength;
|
||||
this.array = new int[cycleLength * iterations];
|
||||
}
|
||||
|
||||
private int get(int relativeIndex) {
|
||||
int actualIndex = (head + relativeIndex) % array.length;
|
||||
return array[actualIndex];
|
||||
}
|
||||
|
||||
private boolean advance() {
|
||||
head = (head + 1) % array.length;
|
||||
return head % cycleLength == 0;
|
||||
}
|
||||
|
||||
private void advance(int amount) {
|
||||
head = (head + amount) % array.length;
|
||||
}
|
||||
|
||||
private void clear() {
|
||||
Arrays.fill(array, 0);
|
||||
head = 0;
|
||||
}
|
||||
|
||||
private void push(int[] cycleArray) {
|
||||
if (cycleArray.length != cycleLength) {
|
||||
throw new IllegalArgumentException("Array size must match cycle length");
|
||||
}
|
||||
|
||||
if (head == 0) {
|
||||
System.arraycopy(cycleArray, 0, array, cycleLength, cycleLength);
|
||||
} else if (head == cycleLength) {
|
||||
System.arraycopy(cycleArray, 0, array, 0, cycleLength);
|
||||
} else {
|
||||
throw new IllegalStateException("Head must be at 0 or cycle length");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
* 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.combat.attack;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundAttackPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.Items;
|
||||
import dev.loki.lovisual.events.Events;
|
||||
import dev.loki.lovisual.events.impl.entity.AttackEntityEvent;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.impl.targethud.TargetHud;
|
||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
|
||||
import dev.loki.lovisual.features.module.modules.misc.sound.HitSounds;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.HitBubbles;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.effects.hits.HitEffect;
|
||||
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
|
||||
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
|
||||
import dev.loki.lovisual.util.item.food.FoodUtil;
|
||||
import dev.loki.lovisual.util.player.interaction.ResetAttackCooldown;
|
||||
import dev.loki.lovisual.util.target.TargetManager;
|
||||
import dev.loki.lovisual.util.combat.rotation.SprintController;
|
||||
|
||||
public enum AttackUtil {
|
||||
;
|
||||
|
||||
public static boolean attack(Minecraft mc, LivingEntity target) {
|
||||
return attack(mc, target, null, null, false, true, false, false);
|
||||
}
|
||||
|
||||
public static boolean attack(Minecraft mc, LivingEntity target, Float yaw, Float pitch, boolean silent) {
|
||||
return attack(mc, target, yaw, pitch, silent, true, false, false);
|
||||
}
|
||||
|
||||
public static boolean attack(Minecraft mc,
|
||||
LivingEntity target,
|
||||
Float yaw,
|
||||
Float pitch,
|
||||
boolean silent,
|
||||
boolean sendRotationPacket) {
|
||||
return attack(mc, target, yaw, pitch, silent, sendRotationPacket, false);
|
||||
}
|
||||
|
||||
public static boolean attack(Minecraft mc,
|
||||
LivingEntity target,
|
||||
Float yaw,
|
||||
Float pitch,
|
||||
boolean silent,
|
||||
boolean sendRotationPacket,
|
||||
boolean keepSprint) {
|
||||
if (mc == null || mc.player == null || mc.gameMode == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
boolean usingItem = player.isUsingItem();
|
||||
|
||||
if (usingItem) {
|
||||
RubberHand rh = Modules.get(RubberHand.class);
|
||||
boolean rhEnabled = rh != null && rh.isEnabled();
|
||||
|
||||
boolean usingShield =
|
||||
player.getUseItem().is(Items.SHIELD) ||
|
||||
player.getOffhandItem().is(Items.SHIELD);
|
||||
boolean usingFood = FoodUtil.isFood(player.getUseItem());
|
||||
|
||||
if (usingFood) {
|
||||
if (!rhEnabled || !rh.canAttackWhileEating()) {
|
||||
return false;
|
||||
}
|
||||
return performAttack(mc, player, target, true);
|
||||
}
|
||||
|
||||
if (usingShield) {
|
||||
if (!rhEnabled || !rh.canAttackWhileShield()) {
|
||||
return false;
|
||||
}
|
||||
boolean usePressed = mc.options.keyUse.isDown();
|
||||
player.releaseUsingItem();
|
||||
mc.options.keyUse.setDown(false);
|
||||
boolean result = performAttack(mc, player, target, keepSprint);
|
||||
if (usePressed) {
|
||||
mc.options.keyUse.setDown(true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean attacked = performAttack(mc, player, target, keepSprint);
|
||||
return attacked;
|
||||
}
|
||||
|
||||
public static boolean attack(Minecraft mc,
|
||||
LivingEntity target,
|
||||
Float yaw,
|
||||
Float pitch,
|
||||
boolean silent,
|
||||
boolean sendRotationPacket,
|
||||
boolean resetSprint,
|
||||
boolean resumeSprint) {
|
||||
return attack(mc, target, yaw, pitch, silent, sendRotationPacket, false);
|
||||
}
|
||||
|
||||
private static boolean performAttack(Minecraft mc,
|
||||
LocalPlayer player,
|
||||
LivingEntity target,
|
||||
boolean keepSprint) {
|
||||
if (keepSprint) {
|
||||
if (!performKeepSprintAttack(mc, player, target)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
mc.gameMode.attack(player, target);
|
||||
}
|
||||
|
||||
player.swing(InteractionHand.MAIN_HAND);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean attackCurrentItem(Minecraft mc, LivingEntity target, boolean keepSprint) {
|
||||
if (mc == null || mc.player == null || mc.gameMode == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return performAttack(mc, mc.player, target, keepSprint);
|
||||
}
|
||||
|
||||
private static boolean performKeepSprintAttack(Minecraft mc,
|
||||
LocalPlayer player,
|
||||
LivingEntity target) {
|
||||
if (mc.getConnection() == null) return false;
|
||||
if (mc.gameMode instanceof MultiPlayerGameModeAccessor accessor) {
|
||||
accessor.lovisual$syncSelectedSlot();
|
||||
}
|
||||
|
||||
notifyLocalAttack(target);
|
||||
mc.getConnection().send(new ServerboundAttackPacket(target.getId()));
|
||||
player.attack(target);
|
||||
player.resetAttackStrengthTicker();
|
||||
ResetAttackCooldown.resetAttackCooldown(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void notifyLocalAttack(LivingEntity target) {
|
||||
LocalPlayer player = Minecraft.getInstance().player;
|
||||
if (player != null && Events.BUS.hasListeners(AttackEntityEvent.class)) {
|
||||
Events.BUS.post(new AttackEntityEvent(player, target));
|
||||
}
|
||||
HitSounds hitSounds = Modules.get(HitSounds.class);
|
||||
if (hitSounds != null) {
|
||||
hitSounds.handleHit(target);
|
||||
}
|
||||
HitEffect hitEffect = Modules.get(HitEffect.class);
|
||||
if (hitEffect != null) {
|
||||
hitEffect.handleHit(target);
|
||||
}
|
||||
HitBubbles hitBubbles = Modules.get(HitBubbles.class);
|
||||
if (hitBubbles != null) {
|
||||
hitBubbles.handleHit(target);
|
||||
}
|
||||
TargetHud.notifyHit(target);
|
||||
TargetManager.onAttack(target);
|
||||
}
|
||||
|
||||
public static void sendRotationPacket(Minecraft mc,
|
||||
LocalPlayer player,
|
||||
float yaw,
|
||||
float pitch) {
|
||||
if (mc.getConnection() == null) return;
|
||||
mc.getConnection().send(new ServerboundMovePlayerPacket.PosRot(
|
||||
player.getX(),
|
||||
player.getY(),
|
||||
player.getZ(),
|
||||
yaw,
|
||||
pitch,
|
||||
player.onGround(),
|
||||
player.horizontalCollision
|
||||
));
|
||||
}
|
||||
|
||||
public static void setSprinting(Minecraft mc, LocalPlayer player, boolean sprinting) {
|
||||
if (player == null) return;
|
||||
if (player.isSprinting() == sprinting) return;
|
||||
player.setSprinting(sprinting);
|
||||
if (mc.getConnection() == null) return;
|
||||
mc.getConnection().send(new ServerboundPlayerCommandPacket(
|
||||
player,
|
||||
sprinting ? ServerboundPlayerCommandPacket.Action.START_SPRINTING
|
||||
: ServerboundPlayerCommandPacket.Action.STOP_SPRINTING
|
||||
));
|
||||
}
|
||||
|
||||
public static void sendStopSprinting(Minecraft mc, LocalPlayer player) {
|
||||
sendStopSprinting(mc, player, false);
|
||||
}
|
||||
|
||||
public static void sendStopSprinting(Minecraft mc, LocalPlayer player, boolean force) {
|
||||
if (player == null || mc.getConnection() == null) return;
|
||||
if (!force && !player.isSprinting() && !SprintController.INSTANCE.isServerSprinting(player)) return;
|
||||
mc.getConnection().send(new ServerboundPlayerCommandPacket(
|
||||
player,
|
||||
ServerboundPlayerCommandPacket.Action.STOP_SPRINTING
|
||||
));
|
||||
if (player instanceof LocalPlayerAccessor accessor) {
|
||||
accessor.lovisual$setLastSprinting(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* 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.combat.attack;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import dev.loki.lovisual.util.click.AttackPressing;
|
||||
import dev.loki.lovisual.util.click.ClickScheduler;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
import dev.loki.lovisual.util.combat.attack.AttackUtil;
|
||||
import dev.loki.lovisual.util.combat.attack.ProtocolAttackExecutor;
|
||||
import dev.loki.lovisual.util.combat.rotation.SprintController;
|
||||
|
||||
/**
|
||||
* Shared combat strike orchestration layer.
|
||||
* Keeps sprint reset, pressing gate and attack execution out of KillAura itself.
|
||||
*/
|
||||
public final class CombatStrikeController {
|
||||
|
||||
public static final CombatStrikeController INSTANCE = new CombatStrikeController();
|
||||
|
||||
private CombatStrikeController() {
|
||||
}
|
||||
|
||||
public boolean isAttackReady(LocalPlayer player,
|
||||
LivingEntity target,
|
||||
boolean legacyProtocol,
|
||||
BooleanSupplier criticalGate) {
|
||||
if (player == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
if (criticalGate != null && !criticalGate.getAsBoolean()) {
|
||||
return false;
|
||||
}
|
||||
int cooldownTicks = legacyProtocol ? 0 : 1;
|
||||
return AttackPressing.INSTANCE.isCooldownComplete(player, target, legacyProtocol, cooldownTicks);
|
||||
}
|
||||
|
||||
public boolean tryAttack(Minecraft mc,
|
||||
LivingEntity target,
|
||||
boolean legacyProtocol,
|
||||
int clickCount,
|
||||
SprintResetMode sprintMode,
|
||||
boolean allowSprintReset,
|
||||
BooleanSupplier criticalGate) {
|
||||
if (mc == null || mc.player == null || target == null || clickCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int cooldownTicks = legacyProtocol ? 0 : 1;
|
||||
if (!AttackPressing.INSTANCE.isCooldownComplete(mc.player, target, legacyProtocol, cooldownTicks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SprintResetMode resolvedMode = resolveSprintResetMode(sprintMode);
|
||||
boolean shouldReset = allowSprintReset
|
||||
&& resolvedMode != SprintResetMode.NONE
|
||||
&& shouldResetSprintForAttack(mc);
|
||||
if (shouldReset && !prepareSprintForAttack(mc, target, resolvedMode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (criticalGate != null && !criticalGate.getAsBoolean()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean attacked = executeAttacksInternal(mc, target, clickCount, legacyProtocol, false);
|
||||
if (shouldReset) {
|
||||
restoreSprintAfterAttack(mc, resolvedMode, true);
|
||||
}
|
||||
return attacked;
|
||||
}
|
||||
|
||||
public int resolveClickCount(ClickScheduler clicker, boolean legacyProtocol) {
|
||||
if (!legacyProtocol) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (clicker == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clickCount = clicker.getClicksAt(0);
|
||||
if (clickCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return clickCount;
|
||||
}
|
||||
|
||||
public boolean prepareSprintForAttack(Minecraft mc,
|
||||
LivingEntity target,
|
||||
SprintResetMode mode) {
|
||||
if (mc == null || mc.player == null || target == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shouldResetSprintForAttack(mc)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return switch (mode) {
|
||||
case DEFAULT -> true;
|
||||
case LEGIT -> SprintController.INSTANCE.prepareLegitAttack(mc, mc.player, 2);
|
||||
case PACKET -> {
|
||||
AttackUtil.sendStopSprinting(mc, mc.player);
|
||||
yield true;
|
||||
}
|
||||
case NONE -> true;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean shouldResetSprintForAttack(Minecraft mc) {
|
||||
if (mc == null || mc.player == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mc.player.isInWater() || mc.player.isUnderWater() || mc.player.isSwimming()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mc.player.isFallFlying()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SprintController.INSTANCE.isServerSprinting(mc.player) || mc.player.isSprinting();
|
||||
}
|
||||
|
||||
public void restoreSprintAfterAttack(Minecraft mc, SprintResetMode mode, boolean shouldReset) {
|
||||
if (!shouldReset || mc == null || mc.player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case DEFAULT, NONE -> {
|
||||
}
|
||||
case LEGIT -> {
|
||||
// Legit reset: no forced instant sprint restore.
|
||||
// Sprint comes back naturally from input/autosprint after short block window.
|
||||
}
|
||||
case PACKET -> {
|
||||
if (mc.getConnection() != null) {
|
||||
mc.getConnection().send(
|
||||
new ServerboundPlayerCommandPacket(mc.player, ServerboundPlayerCommandPacket.Action.START_SPRINTING)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SprintResetMode resolveSprintResetMode(SprintResetMode mode) {
|
||||
if (mode != SprintResetMode.DEFAULT) {
|
||||
return mode;
|
||||
}
|
||||
|
||||
// Sprint module removed; default to legit reset.
|
||||
return SprintResetMode.LEGIT;
|
||||
}
|
||||
|
||||
public void executeAttacks(Minecraft mc, LivingEntity target, int clickCount, boolean legacyProtocol) {
|
||||
executeAttacksInternal(mc, target, clickCount, legacyProtocol, false);
|
||||
}
|
||||
|
||||
public void executeAttacks(Minecraft mc,
|
||||
LivingEntity target,
|
||||
int clickCount,
|
||||
boolean legacyProtocol,
|
||||
boolean keepSprint) {
|
||||
executeAttacksInternal(mc, target, clickCount, legacyProtocol, keepSprint);
|
||||
}
|
||||
|
||||
private boolean executeAttacksInternal(Minecraft mc,
|
||||
LivingEntity target,
|
||||
int clickCount,
|
||||
boolean legacyProtocol,
|
||||
boolean keepSprint) {
|
||||
if (mc == null || mc.player == null || target == null || clickCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean attacked = false;
|
||||
for (int i = 0; i < clickCount; i++) {
|
||||
if (!target.isAlive() || target.isRemoved()) {
|
||||
break;
|
||||
}
|
||||
|
||||
attacked |= ProtocolAttackExecutor.attackOnce(mc, mc.player, target, legacyProtocol, keepSprint);
|
||||
}
|
||||
return attacked;
|
||||
}
|
||||
|
||||
public enum SprintResetMode {
|
||||
DEFAULT,
|
||||
LEGIT,
|
||||
PACKET,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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.combat.attack;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
|
||||
import dev.loki.lovisual.util.item.food.FoodUtil;
|
||||
import dev.loki.lovisual.util.player.interaction.ResetAttackCooldown;
|
||||
import dev.loki.lovisual.util.combat.attack.AttackUtil;
|
||||
|
||||
/**
|
||||
* Shared protocol-aware attack execution.
|
||||
* 1.9+ relies on vanilla cooldown reset from attackEntity.
|
||||
* 1.8 path keeps explicit reset for legacy behavior.
|
||||
*/
|
||||
public enum ProtocolAttackExecutor {
|
||||
;
|
||||
|
||||
public static boolean attackOnce(Minecraft mc, LocalPlayer player, LivingEntity target, boolean legacyProtocol) {
|
||||
return attackOnce(mc, player, target, legacyProtocol, false);
|
||||
}
|
||||
|
||||
public static boolean attackOnce(Minecraft mc,
|
||||
LocalPlayer player,
|
||||
LivingEntity target,
|
||||
boolean legacyProtocol,
|
||||
boolean keepSprint) {
|
||||
if (mc == null || player == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RubberHand rubberHand = Modules.get(RubberHand.class);
|
||||
boolean rubberFoodAttack = player.isUsingItem()
|
||||
&& FoodUtil.isFood(player.getUseItem())
|
||||
&& rubberHand != null
|
||||
&& rubberHand.isEnabled()
|
||||
&& rubberHand.canAttackWhileEating();
|
||||
|
||||
boolean attacked = rubberFoodAttack
|
||||
? AttackUtil.attackCurrentItem(mc, target, keepSprint)
|
||||
: AttackUtil.attack(
|
||||
mc,
|
||||
target,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
keepSprint
|
||||
);
|
||||
|
||||
if (attacked && legacyProtocol) {
|
||||
ResetAttackCooldown.resetAttackCooldown(player);
|
||||
}
|
||||
|
||||
return attacked;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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.combat.hit;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.projectile.ProjectileUtil;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.EntityHitResult;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Точная точка удара по хитбоксу цели — трасса луча от камеры вдоль последнего
|
||||
* известного взгляда игрока. Общая для эффектов, которым важна не позиция цели
|
||||
* целиком, а конкретное место попадания (HitEffect, HitBubbles).
|
||||
*/
|
||||
public enum AttackHitPoint {
|
||||
;
|
||||
|
||||
/** @return точка попадания на хитбоксе {@code target} или null, если трасса не пересекла его. */
|
||||
public static Vec3 resolve(Entity target, float lastYaw, float lastPitch) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) return null;
|
||||
|
||||
double distance = mc.player.distanceTo(target) + 1.0;
|
||||
Vec3 start = mc.player.position().add(0, mc.player.getEyeHeight(mc.player.getPose()), 0);
|
||||
Vec3 rot = rotationVector(lastPitch, lastYaw);
|
||||
Vec3 end = start.add(rot.scale(distance));
|
||||
AABB box = mc.player.getBoundingBox().expandTowards(rot.scale(distance)).inflate(1.0, 1.0, 1.0);
|
||||
|
||||
EntityHitResult hit = ProjectileUtil.getEntityHitResult(
|
||||
mc.player,
|
||||
start,
|
||||
end,
|
||||
box,
|
||||
e -> !e.isSpectator() && e.isPickable() && e == target,
|
||||
distance * distance
|
||||
);
|
||||
|
||||
return hit != null ? hit.getLocation() : null;
|
||||
}
|
||||
|
||||
private static Vec3 rotationVector(float pitch, float yaw) {
|
||||
float yawRad = -yaw * ((float) Math.PI / 180F);
|
||||
float pitchRad = pitch * ((float) Math.PI / 180F);
|
||||
float cosYaw = Mth.cos(yawRad);
|
||||
float sinYaw = Mth.sin(yawRad);
|
||||
float cosPitch = Mth.cos(pitchRad);
|
||||
float sinPitch = Mth.sin(pitchRad);
|
||||
return new Vec3(sinYaw * cosPitch, -sinPitch, cosYaw * cosPitch);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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.combat.protocol;
|
||||
|
||||
/**
|
||||
* Simple protocol version holder (client-side).
|
||||
*/
|
||||
public record ClientProtocolVersion(String name, int version) {
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* 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.combat.protocol;
|
||||
|
||||
import com.viaversion.viafabricplus.ViaFabricPlus;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility;
|
||||
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility1_8;
|
||||
|
||||
import java.util.Map;
|
||||
import dev.loki.lovisual.util.combat.protocol.ClientProtocolVersion;
|
||||
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristics;
|
||||
|
||||
/**
|
||||
* Protocol helpers with optional ViaFabricPlus support.
|
||||
*/
|
||||
public enum ProtocolUtil {
|
||||
;
|
||||
|
||||
public static final ClientProtocolVersion DEFAULT_PROTOCOL_VERSION =
|
||||
new ClientProtocolVersion(SharedConstants.getCurrentVersion().name(), SharedConstants.getProtocolVersion());
|
||||
public static final boolean USES_VIAFABRICPLUS = detectViaFabricPlus();
|
||||
|
||||
private static boolean detectViaFabricPlus() {
|
||||
boolean loaded = FabricLoader.getInstance().isModLoaded("viafabricplus");
|
||||
if (!loaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
ViaFabricPlus.getImpl().registerOnChangeProtocolVersionCallback((oldVer, newVer) -> {
|
||||
// Обновляем заголовок окна лениво — только когда сработает колбэк
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
client.execute(client::updateTitle);
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to register ViaFabricPlus protocol callback", t);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ClientProtocolVersion getProtocolVersion() {
|
||||
if (USES_VIAFABRICPLUS) {
|
||||
try {
|
||||
ClientProtocolVersion version = VfpCompatibility.INSTANCE.unsafeGetProtocolVersion();
|
||||
return version != null ? version : DEFAULT_PROTOCOL_VERSION;
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to get protocol version", t);
|
||||
}
|
||||
}
|
||||
return DEFAULT_PROTOCOL_VERSION;
|
||||
}
|
||||
|
||||
public static ClientProtocolVersion[] getProtocolVersions() {
|
||||
if (USES_VIAFABRICPLUS) {
|
||||
try {
|
||||
return VfpCompatibility.INSTANCE.unsafeGetProtocolVersions();
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to get protocol versions", t);
|
||||
}
|
||||
}
|
||||
return new ClientProtocolVersion[]{DEFAULT_PROTOCOL_VERSION};
|
||||
}
|
||||
|
||||
public static boolean isEqual1_8() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isEqual1_8,
|
||||
"Failed to check if protocol is 1.8");
|
||||
}
|
||||
|
||||
public static boolean isOlderThanOrEqual1_8() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_8,
|
||||
"Failed to check if protocol is <= 1.8");
|
||||
}
|
||||
|
||||
public static boolean isLegacyAttackProtocol(Map<String, Boolean> heuristicSources) {
|
||||
return CombatProtocolHeuristics.INSTANCE.resolveLegacyOrDefault(
|
||||
heuristicSources,
|
||||
isOlderThanOrEqual1_8()
|
||||
);
|
||||
}
|
||||
|
||||
public static boolean isOlderThanOrEquals1_7_10() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_7_10,
|
||||
"Failed to check if protocol is <= 1.7.10");
|
||||
}
|
||||
|
||||
public static boolean isNewerThanOrEquals1_16() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_16,
|
||||
"Failed to check if protocol is >= 1.16");
|
||||
}
|
||||
|
||||
public static boolean isNewerThanOrEquals1_21_5() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_5,
|
||||
"Failed to check if protocol is >= 1.21.5");
|
||||
}
|
||||
|
||||
public static boolean isNewerThanOrEquals1_21_6() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_6,
|
||||
"Failed to check if protocol is >= 1.21.6");
|
||||
}
|
||||
|
||||
public static boolean isNewerThanOrEquals1_21_9() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isNewerThanOrEqual1_21_9,
|
||||
"Failed to check if protocol is >= 1.21.9");
|
||||
}
|
||||
|
||||
public static boolean isOlderThanOrEqual1_11_1() {
|
||||
return USES_VIAFABRICPLUS && safeBoolean(VfpCompatibility.INSTANCE::isOlderThanOrEqual1_11_1,
|
||||
"Failed to check if protocol is <= 1.11.1");
|
||||
}
|
||||
|
||||
public static void selectProtocolVersion(int protocolId) {
|
||||
if (!USES_VIAFABRICPLUS) {
|
||||
throw new IllegalStateException("ViaFabricPlus is not loaded");
|
||||
}
|
||||
VfpCompatibility.INSTANCE.unsafeSelectProtocolVersion(protocolId);
|
||||
}
|
||||
|
||||
public static void openVfpProtocolSelection() {
|
||||
if (!USES_VIAFABRICPLUS) {
|
||||
DebugLog.error("ViaFabricPlus is not loaded");
|
||||
return;
|
||||
}
|
||||
VfpCompatibility.INSTANCE.unsafeOpenVfpProtocolSelection();
|
||||
}
|
||||
|
||||
public static void send1_8SignUpdate(BlockPos blockPos, String[] lines) {
|
||||
if (!USES_VIAFABRICPLUS) {
|
||||
throw new IllegalStateException("ViaFabricPlus is missing");
|
||||
}
|
||||
if (!isEqual1_8()) {
|
||||
throw new IllegalStateException("Not 1.8 protocol");
|
||||
}
|
||||
VfpCompatibility1_8.INSTANCE.sendSignUpdate(blockPos, lines);
|
||||
}
|
||||
|
||||
public static void send1_8PlayerInput(float sideways, float forward, boolean jumping, boolean sneaking) {
|
||||
if (!USES_VIAFABRICPLUS) {
|
||||
throw new IllegalStateException("ViaFabricPlus is missing");
|
||||
}
|
||||
if (!isEqual1_8()) {
|
||||
throw new IllegalStateException("Not 1.8 protocol");
|
||||
}
|
||||
VfpCompatibility1_8.INSTANCE.sendPlayerInput(sideways, forward, jumping, sneaking);
|
||||
}
|
||||
|
||||
private static boolean safeBoolean(BooleanSupplierCall call, String error) {
|
||||
try {
|
||||
return call.getAsBoolean();
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error(error, t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface BooleanSupplierCall {
|
||||
boolean getAsBoolean();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.combat.protocol.heuristics;
|
||||
|
||||
public enum CombatProtocolHeuristicSource {
|
||||
MESSAGES("messages"),
|
||||
OVERLAY("overlay"),
|
||||
BOSSBAR("bossbar");
|
||||
|
||||
private final String key;
|
||||
|
||||
CombatProtocolHeuristicSource(String key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public String key() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* 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.combat.protocol.heuristics;
|
||||
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.pvp.CombatProtocolBossbarEvent;
|
||||
import dev.loki.lovisual.events.impl.pvp.PvpChatEvent;
|
||||
import dev.loki.lovisual.events.impl.pvp.PvpOverlayEvent;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import dev.loki.lovisual.util.text.format.LegacyTextUtil;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicSource;
|
||||
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicsConfig;
|
||||
|
||||
public final class CombatProtocolHeuristics {
|
||||
public static final CombatProtocolHeuristics INSTANCE = new CombatProtocolHeuristics();
|
||||
|
||||
private static final long SIGNAL_TTL_MS = 300_000L;
|
||||
private final Object lock = new Object();
|
||||
private final EnumMap<CombatProtocolHeuristicSource, Signal> signals =
|
||||
new EnumMap<>(CombatProtocolHeuristicSource.class);
|
||||
|
||||
private CombatProtocolHeuristics() {
|
||||
}
|
||||
|
||||
public static LinkedHashMap<String, Boolean> defaultSourceToggles() {
|
||||
LinkedHashMap<String, Boolean> defaults = new LinkedHashMap<>();
|
||||
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
|
||||
defaults.put(source.key(), source == CombatProtocolHeuristicSource.BOSSBAR);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public Optional<Boolean> resolveLegacy(Map<String, Boolean> sourceToggles) {
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (lock) {
|
||||
Signal best = null;
|
||||
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
|
||||
if (!sourceEnabled(sourceToggles, source)) continue;
|
||||
Signal signal = signals.get(source);
|
||||
if (signal == null || now - signal.timeMs > SIGNAL_TTL_MS) continue;
|
||||
if (best == null || signal.timeMs > best.timeMs) {
|
||||
best = signal;
|
||||
}
|
||||
}
|
||||
return best == null ? Optional.empty() : Optional.of(best.legacy);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean resolveLegacyOrDefault(Map<String, Boolean> sourceToggles, boolean fallbackLegacy) {
|
||||
return resolveLegacy(sourceToggles).orElse(fallbackLegacy);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onOverlay(PvpOverlayEvent event) {
|
||||
if (event == null || event.message == null) return;
|
||||
accept(CombatProtocolHeuristicSource.OVERLAY, event.message.getString(), event.timeMs);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onChat(PvpChatEvent event) {
|
||||
if (event == null || event.message == null) return;
|
||||
accept(CombatProtocolHeuristicSource.MESSAGES, event.message, event.timeMs);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onBossbar(CombatProtocolBossbarEvent event) {
|
||||
if (event == null || event.names == null || event.names.isEmpty()) return;
|
||||
for (String name : event.names) {
|
||||
accept(CombatProtocolHeuristicSource.BOSSBAR, name, event.timeMs);
|
||||
}
|
||||
}
|
||||
|
||||
public void accept(CombatProtocolHeuristicSource source, String raw, long timeMs) {
|
||||
if (source == null || raw == null || raw.isBlank()) return;
|
||||
Boolean legacy = detectLegacy(source, raw);
|
||||
if (legacy == null) return;
|
||||
|
||||
synchronized (lock) {
|
||||
signals.put(source, new Signal(legacy, timeMs, raw));
|
||||
}
|
||||
|
||||
if (DebugLog.serverOnly()) {
|
||||
DebugLog.server("Combat protocol heuristic: source=%s legacy=%s raw=\"%s\"",
|
||||
source.key(), legacy, raw);
|
||||
}
|
||||
}
|
||||
|
||||
public static Boolean detectLegacy(String raw) {
|
||||
return detectLegacy(CombatProtocolHeuristicSource.BOSSBAR, raw);
|
||||
}
|
||||
|
||||
public static Boolean detectLegacy(CombatProtocolHeuristicSource source, String raw) {
|
||||
String text = normalize(raw);
|
||||
if (text.isBlank()) return null;
|
||||
|
||||
CombatProtocolHeuristicSource resolvedSource = source == null
|
||||
? CombatProtocolHeuristicSource.BOSSBAR
|
||||
: source;
|
||||
CombatProtocolHeuristicsConfig config = CombatProtocolHeuristicsConfig.get();
|
||||
boolean legacy = matchesAny(text, compile(config.patterns(
|
||||
resolvedSource,
|
||||
CombatProtocolHeuristicsConfig.ProtocolFamily.LEGACY
|
||||
)));
|
||||
boolean modern = matchesAny(text, compile(config.patterns(
|
||||
resolvedSource,
|
||||
CombatProtocolHeuristicsConfig.ProtocolFamily.MODERN
|
||||
)));
|
||||
if (legacy == modern) return null;
|
||||
return legacy;
|
||||
}
|
||||
|
||||
private static boolean sourceEnabled(Map<String, Boolean> toggles, CombatProtocolHeuristicSource source) {
|
||||
return toggles != null && Boolean.TRUE.equals(toggles.get(source.key()));
|
||||
}
|
||||
|
||||
private static String normalize(String raw) {
|
||||
String text = LegacyTextUtil.stripLegacy(raw);
|
||||
if (text == null) return "";
|
||||
return text.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean matchesAny(String text, Pattern[] patterns) {
|
||||
if (text == null || text.isBlank() || patterns == null) return false;
|
||||
for (Pattern pattern : patterns) {
|
||||
if (pattern != null && pattern.matcher(text).find()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Pattern[] compile(Iterable<String> patterns) {
|
||||
if (patterns == null) return new Pattern[0];
|
||||
List<String> values = new java.util.ArrayList<>();
|
||||
for (String pattern : patterns) {
|
||||
if (pattern != null && !pattern.isBlank()) values.add(pattern);
|
||||
}
|
||||
return values.stream()
|
||||
.map(CombatProtocolHeuristics::compileOne)
|
||||
.filter(pattern -> pattern != null)
|
||||
.toArray(Pattern[]::new);
|
||||
}
|
||||
|
||||
private static Pattern compileOne(String pattern) {
|
||||
try {
|
||||
return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
|
||||
} catch (PatternSyntaxException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record Signal(boolean legacy, long timeMs, String raw) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* 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.combat.protocol.heuristics;
|
||||
|
||||
import dev.loki.lovisual.config.definition.ConfigNameProvider;
|
||||
import dev.loki.lovisual.config.definition.ConfigObject;
|
||||
import dev.loki.lovisual.config.io.ConfigSerializer;
|
||||
import dev.loki.lovisual.config.values.collection.ConfigValue;
|
||||
import dev.loki.lovisual.config.values.collection.SetValue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import dev.loki.lovisual.util.combat.protocol.heuristics.CombatProtocolHeuristicSource;
|
||||
|
||||
public final class CombatProtocolHeuristicsConfig implements ConfigObject, ConfigNameProvider {
|
||||
public static final CombatProtocolHeuristicsConfig INSTANCE = new CombatProtocolHeuristicsConfig();
|
||||
|
||||
public static final String DEFAULT_LEGACY_PATTERN = "\\b1[._\\s-]+8(?:\\b|\\+)";
|
||||
public static final String DEFAULT_MODERN_PATTERN = "\\b1[._\\s-]+(?:9|[1-9][0-9])(?:[._\\s-]+[0-9]+)*(?:\\b|\\+)";
|
||||
|
||||
private final EnumMap<CombatProtocolHeuristicSource, SetValue> legacyPatterns =
|
||||
new EnumMap<>(CombatProtocolHeuristicSource.class);
|
||||
private final EnumMap<CombatProtocolHeuristicSource, SetValue> modernPatterns =
|
||||
new EnumMap<>(CombatProtocolHeuristicSource.class);
|
||||
|
||||
private CombatProtocolHeuristicsConfig() {
|
||||
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
|
||||
legacyPatterns.put(source, new SetValue(source.key() + "_legacy_patterns", defaultLegacyPatterns()));
|
||||
modernPatterns.put(source, new SetValue(source.key() + "_modern_patterns", defaultModernPatterns()));
|
||||
}
|
||||
ConfigSerializer.load(this);
|
||||
}
|
||||
|
||||
public static CombatProtocolHeuristicsConfig get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public Set<String> patterns(CombatProtocolHeuristicSource source, ProtocolFamily family) {
|
||||
SetValue value = value(source, family);
|
||||
return value == null ? Set.of() : new LinkedHashSet<>(value.get());
|
||||
}
|
||||
|
||||
public void setPatterns(CombatProtocolHeuristicSource source, ProtocolFamily family, Iterable<String> patterns) {
|
||||
SetValue value = value(source, family);
|
||||
if (value == null) return;
|
||||
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
if (patterns != null) {
|
||||
for (String pattern : patterns) {
|
||||
if (pattern == null) continue;
|
||||
String clean = pattern.trim();
|
||||
if (!clean.isEmpty()) normalized.add(clean);
|
||||
}
|
||||
}
|
||||
value.set(normalized);
|
||||
save();
|
||||
}
|
||||
|
||||
public void reset(CombatProtocolHeuristicSource source) {
|
||||
if (source == null) return;
|
||||
legacyPatterns.get(source).set(defaultLegacyPatterns());
|
||||
modernPatterns.get(source).set(defaultModernPatterns());
|
||||
save();
|
||||
}
|
||||
|
||||
public void save() {
|
||||
ConfigSerializer.requestSave(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigName() {
|
||||
return "combatprotocolheuristics";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConfigValue<?>> getConfigValues() {
|
||||
List<ConfigValue<?>> values = new ArrayList<>();
|
||||
for (CombatProtocolHeuristicSource source : CombatProtocolHeuristicSource.values()) {
|
||||
values.add(legacyPatterns.get(source));
|
||||
values.add(modernPatterns.get(source));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private SetValue value(CombatProtocolHeuristicSource source, ProtocolFamily family) {
|
||||
if (source == null || family == null) return null;
|
||||
return family == ProtocolFamily.LEGACY ? legacyPatterns.get(source) : modernPatterns.get(source);
|
||||
}
|
||||
|
||||
private static Set<String> defaultLegacyPatterns() {
|
||||
return Set.of(DEFAULT_LEGACY_PATTERN);
|
||||
}
|
||||
|
||||
private static Set<String> defaultModernPatterns() {
|
||||
return Set.of(DEFAULT_MODERN_PATTERN);
|
||||
}
|
||||
|
||||
public enum ProtocolFamily {
|
||||
LEGACY,
|
||||
MODERN
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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.combat.protocol.vfp;
|
||||
|
||||
import com.viaversion.viafabricplus.ViaFabricPlus;
|
||||
import com.viaversion.viaversion.api.protocol.version.ProtocolVersion;
|
||||
import com.viaversion.viaversion.api.protocol.version.VersionType;
|
||||
import dev.loki.lovisual.util.screen.ClientScreen;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.screens.TitleScreen;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import dev.loki.lovisual.util.combat.protocol.ClientProtocolVersion;
|
||||
|
||||
/**
|
||||
* Compatibility layer for ViaFabricPlus.
|
||||
* <p>
|
||||
* DO NOT CALL ANY OF THESE METHODS WITHOUT CHECKING IF VIAFABRICPLUS IS LOADED.
|
||||
*/
|
||||
public enum VfpCompatibility {
|
||||
|
||||
INSTANCE;
|
||||
|
||||
public ClientProtocolVersion unsafeGetProtocolVersion() {
|
||||
try {
|
||||
ProtocolVersion version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return new ClientProtocolVersion(version.getName(), version.getVersion());
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to get protocol version", t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public ClientProtocolVersion[] unsafeGetProtocolVersions() {
|
||||
try {
|
||||
var protocols = ProtocolVersion.getProtocols()
|
||||
.stream()
|
||||
.filter(version -> version.getVersionType() == VersionType.RELEASE)
|
||||
.map(version -> new ClientProtocolVersion(version.getName(), version.getVersion()))
|
||||
.toArray(ClientProtocolVersion[]::new);
|
||||
|
||||
for (int left = 0, right = protocols.length - 1; left < right; left++, right--) {
|
||||
var tmp = protocols[left];
|
||||
protocols[left] = protocols[right];
|
||||
protocols[right] = tmp;
|
||||
}
|
||||
return protocols;
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to get protocol versions", t);
|
||||
return new ClientProtocolVersion[0];
|
||||
}
|
||||
}
|
||||
|
||||
public void unsafeOpenVfpProtocolSelection() {
|
||||
try {
|
||||
var currentScreen = ClientScreen.current(Minecraft.getInstance());
|
||||
if (currentScreen == null) {
|
||||
currentScreen = new TitleScreen();
|
||||
}
|
||||
ViaFabricPlus.getImpl().openProtocolSelectionScreen(currentScreen);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to open ViaFabricPlus screen", t);
|
||||
}
|
||||
}
|
||||
|
||||
public void unsafeSelectProtocolVersion(int protocolId) {
|
||||
try {
|
||||
if (!ProtocolVersion.isRegistered(protocolId)) {
|
||||
throw new IllegalArgumentException("Protocol version is not registered");
|
||||
}
|
||||
ProtocolVersion version = ProtocolVersion.getProtocol(protocolId);
|
||||
ViaFabricPlus.getImpl().setTargetVersion(version);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to select protocol version", t);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEqual1_8() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.equalTo(ProtocolVersion.v1_8);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is 1.8", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOlderThanOrEqual1_8() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.olderThanOrEqualTo(ProtocolVersion.v1_8);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is <= 1.8", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOlderThanOrEqual1_7_10() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.olderThanOrEqualTo(ProtocolVersion.v1_7_6);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is <= 1.7.10", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNewerThanOrEqual1_16() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.newerThanOrEqualTo(ProtocolVersion.v1_16);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is >= 1.16", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNewerThanOrEqual1_21_5() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_5);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is >= 1.21.5", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNewerThanOrEqual1_21_6() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_6);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is >= 1.21.6", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNewerThanOrEqual1_21_9() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.newerThanOrEqualTo(ProtocolVersion.v1_21_9);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is >= 1.21.9", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOlderThanOrEqual1_11_1() {
|
||||
try {
|
||||
var version = ViaFabricPlus.getImpl().getTargetVersion();
|
||||
return version.olderThanOrEqualTo(ProtocolVersion.v1_11_1);
|
||||
} catch (Throwable t) {
|
||||
DebugLog.error("Failed to check if protocol is <= 1.11.1", t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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.combat.protocol.vfp;
|
||||
|
||||
import com.viaversion.viafabricplus.ViaFabricPlus;
|
||||
import com.viaversion.viaversion.api.minecraft.BlockPosition;
|
||||
import com.viaversion.viaversion.api.protocol.packet.PacketWrapper;
|
||||
import com.viaversion.viaversion.api.protocol.packet.ServerboundPacketType;
|
||||
import com.viaversion.viaversion.api.type.Types;
|
||||
import com.viaversion.viaversion.protocols.v1_8to1_9.packet.ServerboundPackets1_8;
|
||||
import net.minecraft.core.BlockPos;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import dev.loki.lovisual.util.combat.protocol.vfp.VfpCompatibility;
|
||||
|
||||
/**
|
||||
* Compatibility layer for ViaFabricPlus on protocol 1.8.
|
||||
* <p>
|
||||
* DO NOT CALL ANY OF THESE METHODS WITHOUT CHECKING IF VIAFABRICPLUS IS LOADED AND YOU ARE ON 1.8 PROTOCOL.
|
||||
*/
|
||||
public enum VfpCompatibility1_8 {
|
||||
|
||||
INSTANCE;
|
||||
|
||||
public void sendSignUpdate(final BlockPos blockPos, final String[] lines) throws IllegalArgumentException {
|
||||
if (lines.length != 4) {
|
||||
throw new IllegalArgumentException("Lines length does not match 4");
|
||||
}
|
||||
|
||||
writePacket(ServerboundPackets1_8.SIGN_UPDATE, packet -> {
|
||||
packet.write(Types.BLOCK_POSITION1_8, new BlockPosition(blockPos.getX(), blockPos.getY(), blockPos.getZ()));
|
||||
for (String line : lines) {
|
||||
packet.write(Types.STRING, line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void sendPlayerInput(float sideways, float forwards, boolean jumping, boolean sneaking) {
|
||||
writePacket(ServerboundPackets1_8.PLAYER_INPUT, packet -> {
|
||||
packet.write(Types.FLOAT, sideways);
|
||||
packet.write(Types.FLOAT, forwards);
|
||||
byte b = 0;
|
||||
if (jumping) {
|
||||
b = (byte) (b | 1);
|
||||
}
|
||||
if (sneaking) {
|
||||
b = (byte) (b | 2);
|
||||
}
|
||||
packet.write(Types.BYTE, b);
|
||||
});
|
||||
}
|
||||
|
||||
private void writePacket(ServerboundPacketType packetType, Consumer<PacketWrapper> writer) {
|
||||
if (!VfpCompatibility.INSTANCE.isEqual1_8()) {
|
||||
throw new IllegalStateException("Not on 1.8 protocol");
|
||||
}
|
||||
|
||||
var packet = PacketWrapper.create(packetType, ViaFabricPlus.getImpl().getPlayNetworkUserConnection());
|
||||
writer.accept(packet);
|
||||
packet.sendToServerRaw();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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.combat.rotation;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.config.values.mode.ModeValue;
|
||||
import dev.loki.lovisual.util.raycast.RaycastUtil;
|
||||
import dev.loki.lovisual.util.target.TargetingUtil;
|
||||
|
||||
/**
|
||||
* Shared combat rotation-mode helpers for modules that can operate with or without
|
||||
* synthetic rotation control.
|
||||
*/
|
||||
public enum CombatRotationModeUtil {
|
||||
;
|
||||
|
||||
public static final String MODE_ROTATIONS = "ROTATIONS";
|
||||
public static final String MODE_NO_ROTATIONS = "NO_ROTATIONS";
|
||||
|
||||
public static boolean usesRotations(ModeValue modeValue) {
|
||||
return modeValue != null && MODE_ROTATIONS.equalsIgnoreCase(modeValue.get());
|
||||
}
|
||||
|
||||
public static boolean isNoRotations(ModeValue modeValue) {
|
||||
return modeValue != null && MODE_NO_ROTATIONS.equalsIgnoreCase(modeValue.get());
|
||||
}
|
||||
|
||||
public static boolean canAttackWithoutRotations(Minecraft mc, LivingEntity target, double range) {
|
||||
return canAttackWithoutRotations(mc, target, range, false);
|
||||
}
|
||||
|
||||
public static boolean canAttackWithoutRotations(Minecraft mc,
|
||||
LivingEntity target,
|
||||
double range,
|
||||
boolean requireLineOfSight) {
|
||||
if (mc == null || mc.player == null || target == null || !target.isAlive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
double safeRange = Math.max(0.0, range);
|
||||
Vec3 eyes = mc.player.getEyePosition();
|
||||
double distSq = TargetingUtil.distanceToEntityBoxSq(eyes, target);
|
||||
if (distSq > safeRange * safeRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !requireLineOfSight || hasLineOfSightToEntityBox(eyes, target.getBoundingBox(), safeRange);
|
||||
}
|
||||
|
||||
private static boolean hasLineOfSightToEntityBox(Vec3 eyes, AABB box, double range) {
|
||||
if (eyes == null || box == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (box.contains(eyes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
double maxDistanceSq = range * range + 1.0E-6;
|
||||
double centerX = (box.minX + box.maxX) * 0.5;
|
||||
double centerY = (box.minY + box.maxY) * 0.5;
|
||||
double centerZ = (box.minZ + box.maxZ) * 0.5;
|
||||
double lowerY = box.minY + Math.min(0.25, Math.max(0.0, (box.maxY - box.minY) * 0.25));
|
||||
double upperY = box.maxY - Math.min(0.25, Math.max(0.0, (box.maxY - box.minY) * 0.25));
|
||||
|
||||
return isVisiblePointInRange(eyes, clampToBox(eyes, box), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, centerZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(centerX, lowerY, centerZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(centerX, upperY, centerZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(box.minX, centerY, centerZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(box.maxX, centerY, centerZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, box.minZ), maxDistanceSq)
|
||||
|| isVisiblePointInRange(eyes, new Vec3(centerX, centerY, box.maxZ), maxDistanceSq);
|
||||
}
|
||||
|
||||
private static Vec3 clampToBox(Vec3 point, AABB box) {
|
||||
return new Vec3(
|
||||
clamp(point.x, box.minX, box.maxX),
|
||||
clamp(point.y, box.minY, box.maxY),
|
||||
clamp(point.z, box.minZ, box.maxZ)
|
||||
);
|
||||
}
|
||||
|
||||
private static double clamp(double value, double min, double max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static boolean isVisiblePointInRange(Vec3 eyes, Vec3 point, double maxDistanceSq) {
|
||||
return point != null
|
||||
&& eyes.distanceToSqr(point) <= maxDistanceSq
|
||||
&& RaycastUtil.hasLineOfSightPoint(eyes, point);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* 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.combat.rotation;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.EntityTypeTags;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.damagesource.CombatRules;
|
||||
import net.minecraft.world.damagesource.DamageSource;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.effect.MobEffects;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.EquipmentSlotGroup;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.MaceItem;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.EnchantmentHelper;
|
||||
import net.minecraft.world.item.enchantment.Enchantments;
|
||||
import dev.loki.lovisual.util.player.health.PlayerHealthResolver;
|
||||
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectInference;
|
||||
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectTracker;
|
||||
|
||||
public enum LethalStrikeCalculator {
|
||||
;
|
||||
|
||||
private static final float FULL_STRENGTH_THRESHOLD = 0.9f;
|
||||
private static final float LETHAL_MARGIN = 1.25f;
|
||||
|
||||
public static boolean canKillNow(Player attacker, LivingEntity target, int cooldownTicks) {
|
||||
if (attacker == null || target == null || !target.isAlive() || target.isRemoved()) {
|
||||
return false;
|
||||
}
|
||||
if (!target.isAttackable() || target.isInvulnerable()) {
|
||||
return false;
|
||||
}
|
||||
if (target instanceof Player player && hasUnstableDefensiveState(player)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float damage = estimateFinalDamage(attacker, target, Math.max(0, cooldownTicks));
|
||||
float effectiveHealth = PlayerHealthResolver.resolve(target).totalHealth();
|
||||
return damage >= effectiveHealth + LETHAL_MARGIN;
|
||||
}
|
||||
|
||||
private static float estimateFinalDamage(Player attacker, LivingEntity target, int cooldownTicks) {
|
||||
ItemStack weapon = attacker.getMainHandItem();
|
||||
DamageSource source = getDamageSource(attacker, weapon);
|
||||
|
||||
float cooldown = attacker.getAttackStrengthScale(cooldownTicks + 0.5f);
|
||||
float cooldownScale = 0.2f + cooldown * cooldown * 0.8f;
|
||||
|
||||
float baseDamage = (float) attacker.getAttributeValue(Attributes.ATTACK_DAMAGE);
|
||||
float handBase = (float) attacker.getAttributeBaseValue(Attributes.ATTACK_DAMAGE);
|
||||
float weaponBonus = getMainHandAttackDamageBonus(weapon);
|
||||
if (weaponBonus > 0.0f && Math.abs(baseDamage - handBase) < 0.0001f) {
|
||||
baseDamage += weaponBonus;
|
||||
}
|
||||
|
||||
float enchantDamage = getEnchantmentDamage(attacker, target, weapon, source, baseDamage) - baseDamage;
|
||||
if (enchantDamage < 0.0f) {
|
||||
enchantDamage = 0.0f;
|
||||
}
|
||||
|
||||
float damage = baseDamage * cooldownScale + enchantDamage * cooldown;
|
||||
damage += weapon.getItem().getAttackDamageBonus(target, damage, source);
|
||||
if (!(attacker.level() instanceof ServerLevel)) {
|
||||
damage += getClientMaceDensityBonus(attacker, target, weapon, source);
|
||||
}
|
||||
|
||||
if (cooldown > FULL_STRENGTH_THRESHOLD && isCriticalHit(attacker, target)) {
|
||||
damage *= 1.5f;
|
||||
}
|
||||
|
||||
if (damage <= 0.0f) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
damage = applyArmor(attacker, target, weapon, source, damage);
|
||||
damage = applyResistance(target, damage);
|
||||
damage = applyProtection(target, source, damage);
|
||||
return Math.max(damage, 0.0f);
|
||||
}
|
||||
|
||||
private static DamageSource getDamageSource(Player attacker, ItemStack weapon) {
|
||||
if (attacker.level() != null) {
|
||||
return weapon.getDamageSource(attacker);
|
||||
}
|
||||
return attacker.damageSources().playerAttack(attacker);
|
||||
}
|
||||
|
||||
private static float getEnchantmentDamage(Player attacker,
|
||||
Entity target,
|
||||
ItemStack weapon,
|
||||
DamageSource source,
|
||||
float baseDamage) {
|
||||
if (attacker.level() instanceof ServerLevel serverWorld) {
|
||||
return EnchantmentHelper.modifyDamage(serverWorld, weapon, target, source, baseDamage);
|
||||
}
|
||||
|
||||
float damage = baseDamage;
|
||||
int sharpness = getEnchantmentLevel(attacker, Enchantments.SHARPNESS, weapon);
|
||||
if (sharpness > 0) {
|
||||
damage += 1.0f + (sharpness - 1) * 0.5f;
|
||||
}
|
||||
|
||||
int smite = getEnchantmentLevel(attacker, Enchantments.SMITE, weapon);
|
||||
if (smite > 0 && target.getType().builtInRegistryHolder().is(EntityTypeTags.SENSITIVE_TO_SMITE)) {
|
||||
damage += smite * 2.5f;
|
||||
}
|
||||
|
||||
int bane = getEnchantmentLevel(attacker, Enchantments.BANE_OF_ARTHROPODS, weapon);
|
||||
if (bane > 0 && target.getType().builtInRegistryHolder().is(EntityTypeTags.SENSITIVE_TO_BANE_OF_ARTHROPODS)) {
|
||||
damage += bane * 2.5f;
|
||||
}
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
private static float applyArmor(Player attacker,
|
||||
LivingEntity target,
|
||||
ItemStack weapon,
|
||||
DamageSource source,
|
||||
float damage) {
|
||||
float armor = target.getArmorValue();
|
||||
float toughness = (float) target.getAttributeValue(Attributes.ARMOR_TOUGHNESS);
|
||||
if (attacker.level() instanceof ServerLevel) {
|
||||
return CombatRules.getDamageAfterAbsorb(target, damage, source, armor, toughness);
|
||||
}
|
||||
|
||||
float f = 2.0f + toughness / 4.0f;
|
||||
float g = Mth.clamp(armor - damage / f, armor * 0.2f, 20.0f);
|
||||
float armorEffectiveness = getArmorEffectiveness(attacker, weapon, g / 25.0f);
|
||||
armorEffectiveness = Mth.clamp(armorEffectiveness, 0.0f, 1.0f);
|
||||
return damage * (1.0f - armorEffectiveness);
|
||||
}
|
||||
|
||||
private static float getArmorEffectiveness(Player attacker, ItemStack weapon, float baseArmorEffectiveness) {
|
||||
int breach = getEnchantmentLevel(attacker, Enchantments.BREACH, weapon);
|
||||
if (breach <= 0) {
|
||||
return baseArmorEffectiveness;
|
||||
}
|
||||
return baseArmorEffectiveness - 0.15f * breach;
|
||||
}
|
||||
|
||||
private static float getClientMaceDensityBonus(Player attacker,
|
||||
Entity target,
|
||||
ItemStack weapon,
|
||||
DamageSource source) {
|
||||
if (!(weapon.getItem() instanceof MaceItem) || !MaceItem.canSmashAttack(attacker)) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
int density = getEnchantmentLevel(attacker, Enchantments.DENSITY, weapon);
|
||||
return density > 0 ? (float) (density * 0.5f * attacker.fallDistance) : 0.0f;
|
||||
}
|
||||
|
||||
private static float applyResistance(LivingEntity target, float damage) {
|
||||
MobEffectInstance resistance = target.getEffect(MobEffects.RESISTANCE);
|
||||
if (resistance == null) {
|
||||
return damage;
|
||||
}
|
||||
|
||||
int reduced = 25 - (resistance.getAmplifier() + 1) * 5;
|
||||
return Math.max(damage * reduced / 25.0f, 0.0f);
|
||||
}
|
||||
|
||||
private static float applyProtection(LivingEntity target, DamageSource source, float damage) {
|
||||
float protection = getProtectionAmount(target);
|
||||
if (protection <= 0.0f || source.is(net.minecraft.tags.DamageTypeTags.BYPASSES_INVULNERABILITY)) {
|
||||
return damage;
|
||||
}
|
||||
return CombatRules.getDamageAfterMagicAbsorb(damage, protection);
|
||||
}
|
||||
|
||||
private static float getProtectionAmount(LivingEntity target) {
|
||||
float total = 0.0f;
|
||||
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.HEAD));
|
||||
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.CHEST));
|
||||
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.LEGS));
|
||||
total += getEnchantmentLevel(target, Enchantments.PROTECTION, target.getItemBySlot(EquipmentSlot.FEET));
|
||||
return total;
|
||||
}
|
||||
|
||||
private static float getMainHandAttackDamageBonus(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
final double[] add = {0.0};
|
||||
final double[] mulBase = {0.0};
|
||||
final double[] mulTotal = {1.0};
|
||||
stack.forEachModifier(EquipmentSlotGroup.MAINHAND, (attribute, modifier, display) -> {
|
||||
if (!attribute.equals(Attributes.ATTACK_DAMAGE)) {
|
||||
return;
|
||||
}
|
||||
AttributeModifier.Operation op = modifier.operation();
|
||||
double amount = modifier.amount();
|
||||
if (op == AttributeModifier.Operation.ADD_VALUE) {
|
||||
add[0] += amount;
|
||||
} else if (op == AttributeModifier.Operation.ADD_MULTIPLIED_BASE) {
|
||||
mulBase[0] += amount;
|
||||
} else if (op == AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL) {
|
||||
mulTotal[0] *= 1.0 + amount;
|
||||
}
|
||||
});
|
||||
|
||||
return (float) ((add[0] + mulBase[0]) * mulTotal[0]);
|
||||
}
|
||||
|
||||
private static boolean isCriticalHit(Player attacker, Entity target) {
|
||||
return attacker.fallDistance > 0.0f
|
||||
&& !attacker.onGround()
|
||||
&& !attacker.onClimbable()
|
||||
&& !attacker.isInWater()
|
||||
&& !attacker.isMobilityRestricted()
|
||||
&& !attacker.isPassenger()
|
||||
&& target instanceof LivingEntity
|
||||
&& !attacker.isSprinting();
|
||||
}
|
||||
|
||||
private static boolean hasUnstableDefensiveState(Player player) {
|
||||
if (StatusEffectInference.isDefensiveConsumeLikely(player)
|
||||
|| StatusEffectInference.hasPendingDefensiveEffect(player)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int entityId = player.getId();
|
||||
for (MobEffectInstance effect : StatusEffectTracker.get(entityId)) {
|
||||
Holder<MobEffect> type = effect.getEffect();
|
||||
if (StatusEffectInference.isDefensiveHealthEffect(type)
|
||||
&& StatusEffectTracker.isHeuristic(entityId, type)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int getEnchantmentLevel(LivingEntity entity, ResourceKey<Enchantment> key, ItemStack stack) {
|
||||
Holder<Enchantment> entry = getEnchantmentEntry(entity, key);
|
||||
return entry != null ? EnchantmentHelper.getItemEnchantmentLevel(entry, stack) : 0;
|
||||
}
|
||||
|
||||
private static Holder<Enchantment> getEnchantmentEntry(LivingEntity entity, ResourceKey<Enchantment> key) {
|
||||
if (entity == null || entity.level() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Registry<Enchantment> registry = entity.level().registryAccess().lookupOrThrow(Registries.ENCHANTMENT);
|
||||
Enchantment enchantment = registry.getValue(key);
|
||||
return enchantment != null ? registry.wrapAsHolder(enchantment) : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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.combat.rotation;
|
||||
|
||||
import dev.loki.lovisual.features.module.lifecycle.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.combat.pvp.RubberHand;
|
||||
import dev.loki.lovisual.util.item.food.FoodUtil;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
public final class RubberHandUseUtil {
|
||||
private RubberHandUseUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a block-use action while RubberHand allows bypassing the currently active item.
|
||||
*
|
||||
* The helper mirrors RubberHand's release/restore pattern for module block interactions, so
|
||||
* AutoAnchor/AutoBed can place and detonate while shield/food active-use bypass is enabled
|
||||
* without permanently dropping the user's held use key.
|
||||
*/
|
||||
public static boolean runBlockUse(Minecraft mc, BooleanSupplier action) {
|
||||
if (action == null) return false;
|
||||
if (mc == null || mc.player == null) return action.getAsBoolean();
|
||||
|
||||
LocalPlayer player = mc.player;
|
||||
RubberHand rubberHand = Modules.get(RubberHand.class);
|
||||
if (!canBypassCurrentUse(mc, player, rubberHand)) {
|
||||
return action.getAsBoolean();
|
||||
}
|
||||
|
||||
boolean restoreUseKey = mc.options != null && mc.options.keyUse.isDown();
|
||||
player.releaseUsingItem();
|
||||
if (mc.options != null) {
|
||||
mc.options.keyUse.setDown(false);
|
||||
}
|
||||
|
||||
try {
|
||||
return action.getAsBoolean();
|
||||
} finally {
|
||||
if (restoreUseKey && mc.options != null) {
|
||||
mc.options.keyUse.setDown(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canBypassCurrentUse(Minecraft mc) {
|
||||
if (mc == null || mc.player == null) return false;
|
||||
return canBypassCurrentUse(mc, mc.player, Modules.get(RubberHand.class));
|
||||
}
|
||||
|
||||
public static boolean isUsingShield(LocalPlayer player) {
|
||||
if (player == null || !player.isUsingItem()) return false;
|
||||
return player.getUseItem().is(Items.SHIELD) || player.getOffhandItem().is(Items.SHIELD);
|
||||
}
|
||||
|
||||
public static boolean isUsingFood(LocalPlayer player) {
|
||||
return player != null && player.isUsingItem() && FoodUtil.isFood(player.getUseItem());
|
||||
}
|
||||
|
||||
private static boolean canBypassCurrentUse(Minecraft mc, LocalPlayer player, RubberHand rubberHand) {
|
||||
if (mc == null
|
||||
|| mc.options == null
|
||||
|| player == null
|
||||
|| rubberHand == null
|
||||
|| !rubberHand.isEnabled()
|
||||
|| !player.isUsingItem()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isUsingShield(player)) {
|
||||
return rubberHand.canBypassShieldUse();
|
||||
}
|
||||
|
||||
if (isUsingFood(player)) {
|
||||
return rubberHand.canBypassFoodUse();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
/*
|
||||
* 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.combat.rotation;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
import dev.loki.lovisual.events.impl.network.PacketEvent;
|
||||
import dev.loki.lovisual.events.impl.input.SprintControlEvent;
|
||||
import dev.loki.lovisual.mixins.accessors.entity.LocalPlayerAccessor;
|
||||
import dev.loki.lovisual.util.combat.attack.AttackUtil;
|
||||
|
||||
/**
|
||||
* Shared sprint state/controller layer.
|
||||
* <p>
|
||||
* AutoSprint is applied only as PlayerInput.sprint through Source.INPUT.
|
||||
* Movement/network hooks may only force sprint false when vanilla would stop it
|
||||
* or when legit attack reset blocks sprint for a few ticks.
|
||||
*/
|
||||
public final class SprintController {
|
||||
|
||||
public static final SprintController INSTANCE = new SprintController();
|
||||
private static final int MAX_LEGIT_BLOCK_TICKS = 3;
|
||||
private boolean serverSprinting;
|
||||
private int blockSprintUntilAge = Integer.MIN_VALUE;
|
||||
private int requestedSprintInputUntilAge = Integer.MIN_VALUE;
|
||||
|
||||
private SprintController() {
|
||||
}
|
||||
|
||||
public void requestLegitStop(Minecraft mc, LocalPlayer player, int blockTicks) {
|
||||
if (mc == null || player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int safeBlockTicks = Math.max(1, Math.min(MAX_LEGIT_BLOCK_TICKS, blockTicks));
|
||||
|
||||
serverSprinting = false;
|
||||
blockSprintUntilAge = Math.max(blockSprintUntilAge, player.tickCount + safeBlockTicks);
|
||||
requestedSprintInputUntilAge = Integer.MIN_VALUE;
|
||||
|
||||
player.setSprinting(false);
|
||||
AttackUtil.sendStopSprinting(mc, player, true);
|
||||
}
|
||||
|
||||
public boolean prepareLegitAttack(Minecraft mc, LocalPlayer player, int blockTicks) {
|
||||
if (player == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean localSprinting = player.isSprinting();
|
||||
boolean remoteSprinting = isServerSprinting(player);
|
||||
|
||||
if (!localSprinting && !remoteSprinting) {
|
||||
return true;
|
||||
}
|
||||
|
||||
requestLegitStop(mc, player, blockTicks);
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isServerSprinting(LocalPlayer player) {
|
||||
return player != null
|
||||
&& serverSprinting
|
||||
&& !player.isFallFlying()
|
||||
&& !player.isInWater();
|
||||
}
|
||||
|
||||
public void clearSprintBlock() {
|
||||
blockSprintUntilAge = Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
public boolean isSprintBlocked() {
|
||||
return shouldBlockSprint();
|
||||
}
|
||||
|
||||
public boolean canStartSprinting(LocalPlayer player) {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBlockSprint()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldStopSprintingForControl(player)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ((LocalPlayerAccessor) player).lovisual$canStartSprinting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility for old callers.
|
||||
* Does not call setSprinting(true). It requests sprint input instead.
|
||||
*/
|
||||
public boolean requestStartSprinting(Minecraft mc, LocalPlayer player, boolean hasForwardMotion) {
|
||||
if (mc == null || player == null || !hasForwardMotion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBlockSprint()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldStopSprintingForControl(player)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.input == null || !player.input.hasForwardImpulse()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
requestedSprintInputUntilAge = Math.max(requestedSprintInputUntilAge, player.tickCount + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean requestStartSprinting(Minecraft mc,
|
||||
LocalPlayer player,
|
||||
boolean hasForwardMotion,
|
||||
int ticks) {
|
||||
if (mc == null || player == null || !hasForwardMotion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBlockSprint() || shouldStopSprintingForControl(player)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.input == null || !player.input.hasForwardImpulse()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int safeTicks = Math.max(1, ticks);
|
||||
requestedSprintInputUntilAge = Math.max(requestedSprintInputUntilAge, player.tickCount + safeTicks);
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onSprintControl(SprintControlEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
if (player == null || player.input == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBlockSprint() || shouldStopSprintingForControl(player)) {
|
||||
event.setSprint(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getSource() == SprintControlEvent.Source.INPUT) {
|
||||
if (shouldPressSprintInput(player, event.shouldSprint())) {
|
||||
event.setSprint(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldPressSprintInput(LocalPlayer player, boolean original) {
|
||||
if (player == null || player.input == null) {
|
||||
return original;
|
||||
}
|
||||
|
||||
if (isSprintInputRequested(player) && player.input.hasForwardImpulse()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
private boolean isSprintInputRequested(LocalPlayer player) {
|
||||
if (player == null) {
|
||||
requestedSprintInputUntilAge = Integer.MIN_VALUE;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requestedSprintInputUntilAge == Integer.MIN_VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.tickCount > requestedSprintInputUntilAge) {
|
||||
requestedSprintInputUntilAge = Integer.MIN_VALUE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean shouldVanillaStopSprinting(LocalPlayer player) {
|
||||
LocalPlayerAccessor accessor = (LocalPlayerAccessor) player;
|
||||
|
||||
if (player.isSwimming()) {
|
||||
return accessor.lovisual$shouldStopSwimSprinting();
|
||||
}
|
||||
|
||||
return accessor.lovisual$shouldStopSprinting();
|
||||
}
|
||||
|
||||
public boolean shouldStopSprintingForControl(LocalPlayer player) {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBlockSprint()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shouldVanillaStopSprinting(player)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !player.horizontalCollision || shouldStopAtHorizontalObstacle();
|
||||
}
|
||||
|
||||
private boolean shouldStopAtHorizontalObstacle() {
|
||||
// Sprint module removed; always stop sprinting at horizontal obstacles.
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = 3000)
|
||||
private void onPacketSend(PacketEvent.Send event) {
|
||||
if (!(event.getPacket() instanceof ServerboundPlayerCommandPacket command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.getAction() == ServerboundPlayerCommandPacket.Action.START_SPRINTING && shouldBlockSprint()) {
|
||||
event.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketSendPost(PacketEvent.SendPost event) {
|
||||
if (!(event.getPacket() instanceof ServerboundPlayerCommandPacket command)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command.getAction()) {
|
||||
case START_SPRINTING -> {
|
||||
serverSprinting = true;
|
||||
clearSprintBlock();
|
||||
}
|
||||
case STOP_SPRINTING -> serverSprinting = false;
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onGameTick(GameTickEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
if (mc.player != null && mc.level != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverSprinting = false;
|
||||
clearSprintBlock();
|
||||
requestedSprintInputUntilAge = Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
private boolean shouldBlockSprint() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
if (player == null) {
|
||||
clearSprintBlock();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blockSprintUntilAge == Integer.MIN_VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (player.tickCount >= blockSprintUntilAge) {
|
||||
clearSprintBlock();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
* 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.combat.tracking;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.PlayerInfo;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.equipment.Equippable;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
import dev.loki.lovisual.events.impl.network.PacketEvent;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class AntiBotTracker {
|
||||
public static final AntiBotTracker INSTANCE = new AntiBotTracker();
|
||||
|
||||
private static final long SUSPECT_TTL_MS = 8_000L;
|
||||
private static final long TAB_TTL_MS = 30_000L;
|
||||
private static final int BOT_SCORE = 4;
|
||||
private static final EquipmentSlot[] ARMOR_SLOTS = {
|
||||
EquipmentSlot.HEAD,
|
||||
EquipmentSlot.CHEST,
|
||||
EquipmentSlot.LEGS,
|
||||
EquipmentSlot.FEET
|
||||
};
|
||||
|
||||
private final ConcurrentHashMap<UUID, TabInfo> tab = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<UUID, SuspectInfo> suspects = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap.KeySetView<UUID, Boolean> bots = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private AntiBotTracker() {
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketReceivePost(PacketEvent.ReceivePost event) {
|
||||
if (event.getPacket() instanceof ClientboundPlayerInfoUpdatePacket packet) {
|
||||
handlePlayerList(packet);
|
||||
} else if (event.getPacket() instanceof ClientboundPlayerInfoRemovePacket packet) {
|
||||
handlePlayerRemove(packet);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onGameTick(GameTickEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null || mc.player == null) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
suspects.entrySet().removeIf(entry -> now - entry.getValue().seenAtMs > SUSPECT_TTL_MS);
|
||||
tab.entrySet().removeIf(entry -> {
|
||||
UUID uuid = entry.getKey();
|
||||
return now - entry.getValue().updatedAtMs > TAB_TTL_MS
|
||||
&& mc.getConnection() != null
|
||||
&& mc.getConnection().getPlayerInfo(uuid) == null
|
||||
&& mc.level.getPlayerByUUID(uuid) == null;
|
||||
});
|
||||
bots.removeIf(uuid -> mc.level.getPlayerByUUID(uuid) == null && !isKnownInTab(uuid));
|
||||
|
||||
for (UUID uuid : suspects.keySet()) {
|
||||
Player player = mc.level.getPlayerByUUID(uuid);
|
||||
if (player != null && assess(player).score() >= BOT_SCORE) {
|
||||
bots.add(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isBot(Player player) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (player == null || mc.player == null || player == mc.player) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UUID uuid = player.getUUID();
|
||||
if (bots.contains(uuid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
BotAssessment assessment = assess(player);
|
||||
if (assessment.score() >= BOT_SCORE) {
|
||||
bots.add(uuid);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
tab.clear();
|
||||
suspects.clear();
|
||||
bots.clear();
|
||||
}
|
||||
|
||||
private void handlePlayerList(ClientboundPlayerInfoUpdatePacket packet) {
|
||||
long now = System.currentTimeMillis();
|
||||
boolean add = packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER);
|
||||
boolean listedAffects = add || packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED);
|
||||
boolean latencyAffects = add || packet.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LATENCY);
|
||||
|
||||
for (ClientboundPlayerInfoUpdatePacket.Entry entry : packet.entries()) {
|
||||
UUID uuid = entry.profileId();
|
||||
TabInfo info = tab.computeIfAbsent(uuid, TabInfo::new);
|
||||
info.updatedAtMs = now;
|
||||
|
||||
GameProfile profile = entry.profile();
|
||||
if (profile != null) {
|
||||
info.name = profile.name();
|
||||
info.propertiesMissing = profile.properties() == null || profile.properties().isEmpty();
|
||||
}
|
||||
if (listedAffects) {
|
||||
info.listed = entry.listed();
|
||||
info.listedKnown = true;
|
||||
}
|
||||
if (latencyAffects) {
|
||||
info.latency = entry.latency();
|
||||
info.latencyKnown = true;
|
||||
}
|
||||
|
||||
if (add && profile != null) {
|
||||
if (hasDuplicateProfile(profile.name(), profile.id())) {
|
||||
bots.add(uuid);
|
||||
} else if (isSuspiciousAddition(entry, profile)) {
|
||||
suspects.put(uuid, new SuspectInfo(now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePlayerRemove(ClientboundPlayerInfoRemovePacket packet) {
|
||||
for (UUID uuid : packet.profileIds()) {
|
||||
tab.remove(uuid);
|
||||
suspects.remove(uuid);
|
||||
bots.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
private BotAssessment assess(Player player) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
String name = player.getGameProfile().name();
|
||||
UUID uuid = player.getUUID();
|
||||
TabInfo info = tab.get(uuid);
|
||||
PlayerInfo networkEntry = mc.getConnection() != null
|
||||
? mc.getConnection().getPlayerInfo(uuid)
|
||||
: null;
|
||||
|
||||
boolean knownInTab = info != null || networkEntry != null;
|
||||
boolean listed = info == null || !info.listedKnown || info.listed;
|
||||
boolean suspect = suspects.containsKey(uuid);
|
||||
boolean profilePropertiesMissing = info != null && info.propertiesMissing;
|
||||
boolean npcName = looksLikeNpcName(name);
|
||||
|
||||
int score = 0;
|
||||
if (isMatrixNameBot(name)) {
|
||||
score += 4;
|
||||
}
|
||||
if (hasDuplicateProfile(name, uuid)) {
|
||||
score += 4;
|
||||
}
|
||||
if (!knownInTab && player.tickCount > 20) {
|
||||
score += 4;
|
||||
} else if (!listed && player.tickCount > 40) {
|
||||
score += 3;
|
||||
}
|
||||
if (suspect) {
|
||||
score += 2;
|
||||
}
|
||||
if (profilePropertiesMissing && suspect) {
|
||||
score += 1;
|
||||
}
|
||||
if (player.isInvisible() && (!knownInTab || !listed) && !npcName) {
|
||||
score += 3;
|
||||
}
|
||||
if (suspect && isFullyCleanArmored(player)) {
|
||||
score += 2;
|
||||
}
|
||||
if (isNonOfflineUuid(player) && player.isInvisible() && !npcName && (!knownInTab || !listed)) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
return new BotAssessment(score);
|
||||
}
|
||||
|
||||
private boolean isSuspiciousAddition(ClientboundPlayerInfoUpdatePacket.Entry entry, GameProfile profile) {
|
||||
if (profile == null || profile.name() == null || profile.name().isBlank()) {
|
||||
return true;
|
||||
}
|
||||
if (looksLikeNpcName(profile.name())) {
|
||||
return false;
|
||||
}
|
||||
boolean propertiesMissing = profile.properties() == null || profile.properties().isEmpty();
|
||||
return propertiesMissing && entry.latency() >= 2;
|
||||
}
|
||||
|
||||
private boolean hasDuplicateProfile(String name, UUID self) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (name == null || name.isBlank() || self == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalized = name.toLowerCase(Locale.ROOT);
|
||||
for (TabInfo info : tab.values()) {
|
||||
if (info.name != null
|
||||
&& info.name.toLowerCase(Locale.ROOT).equals(normalized)
|
||||
&& !self.equals(info.uuid)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mc.getConnection() == null) {
|
||||
return false;
|
||||
}
|
||||
for (PlayerInfo entry : mc.getConnection().getOnlinePlayers()) {
|
||||
GameProfile profile = entry.getProfile();
|
||||
if (profile != null
|
||||
&& profile.name() != null
|
||||
&& profile.name().equalsIgnoreCase(name)
|
||||
&& !self.equals(profile.id())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isKnownInTab(UUID uuid) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (uuid == null) {
|
||||
return false;
|
||||
}
|
||||
if (tab.containsKey(uuid)) {
|
||||
return true;
|
||||
}
|
||||
return mc.getConnection() != null && mc.getConnection().getPlayerInfo(uuid) != null;
|
||||
}
|
||||
|
||||
private boolean isFullyCleanArmored(Player player) {
|
||||
for (EquipmentSlot slot : ARMOR_SLOTS) {
|
||||
ItemStack stack = player.getItemBySlot(slot);
|
||||
Equippable equippable = stack.get(DataComponents.EQUIPPABLE);
|
||||
if (stack.isEmpty() || equippable == null || equippable.slot() != slot || stack.isEnchanted()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isMatrixNameBot(String name) {
|
||||
return name != null
|
||||
&& name.startsWith("CIT-")
|
||||
&& !name.contains("NPC")
|
||||
&& !name.contains("[ZNPC]");
|
||||
}
|
||||
|
||||
private boolean looksLikeNpcName(String name) {
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String upper = name.toUpperCase(Locale.ROOT);
|
||||
return upper.contains("NPC") || upper.startsWith("[ZNPC]");
|
||||
}
|
||||
|
||||
private boolean isNonOfflineUuid(Player player) {
|
||||
String name = player.getGameProfile().name();
|
||||
if (name == null || name.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
UUID offline = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
|
||||
return !player.getUUID().equals(offline);
|
||||
}
|
||||
|
||||
private static final class TabInfo {
|
||||
private final UUID uuid;
|
||||
private String name;
|
||||
private boolean listed = true;
|
||||
private boolean listedKnown;
|
||||
private int latency;
|
||||
private boolean latencyKnown;
|
||||
private boolean propertiesMissing;
|
||||
private long updatedAtMs;
|
||||
|
||||
private TabInfo(UUID uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
}
|
||||
|
||||
private record SuspectInfo(long seenAtMs) {
|
||||
}
|
||||
|
||||
private record BotAssessment(int score) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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.combat.tracking;
|
||||
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.util.Locale;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.overlay.ReachDisplay;
|
||||
|
||||
/**
|
||||
* Tracks the reach (eye -> target hitbox distance) of the player's last attack
|
||||
* for the ReachDisplay HUD widget. Measurement math is pure and unit-tested;
|
||||
* state is a volatile snapshot written on the attack event.
|
||||
*/
|
||||
public final class ReachMeter {
|
||||
|
||||
/** Vanilla survival attack range, m. Distances above are tinted as overreach. */
|
||||
public static final double VANILLA_REACH = 3.0;
|
||||
|
||||
private static volatile double lastDistance = -1.0;
|
||||
private static volatile double sessionMax = -1.0;
|
||||
private static volatile long lastAttackAtMs;
|
||||
|
||||
private ReachMeter() {
|
||||
}
|
||||
|
||||
public static void record(double distance, long nowMs) {
|
||||
if (Double.isNaN(distance) || distance < 0.0) return;
|
||||
lastDistance = distance;
|
||||
lastAttackAtMs = nowMs;
|
||||
if (distance > sessionMax) {
|
||||
sessionMax = distance;
|
||||
}
|
||||
}
|
||||
|
||||
public static double lastDistance() {
|
||||
return lastDistance;
|
||||
}
|
||||
|
||||
public static double sessionMax() {
|
||||
return sessionMax;
|
||||
}
|
||||
|
||||
public static long lastAttackAtMs() {
|
||||
return lastAttackAtMs;
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
lastDistance = -1.0;
|
||||
sessionMax = -1.0;
|
||||
lastAttackAtMs = 0L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance from a point to the closest point of an AABB (per-axis clamp).
|
||||
* Returns 0 when the point is inside the box.
|
||||
*/
|
||||
public static double distanceToBox(Vec3 point, AABB box) {
|
||||
if (point == null || box == null) return -1.0;
|
||||
double dx = axisDelta(point.x, box.minX, box.maxX);
|
||||
double dy = axisDelta(point.y, box.minY, box.maxY);
|
||||
double dz = axisDelta(point.z, box.minZ, box.maxZ);
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static double axisDelta(double value, double min, double max) {
|
||||
if (value < min) return min - value;
|
||||
if (value > max) return value - max;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** Formats meters for display, e.g. "3.12 m"; "-" when no measurement yet. */
|
||||
public static String format(double distance) {
|
||||
if (distance < 0.0) return "-";
|
||||
return String.format(Locale.ROOT, "%.2f m", distance);
|
||||
}
|
||||
}
|
||||
371
mod/src/main/java/dev/loki/lovisual/util/entity/EagleUtil.java
Normal file
371
mod/src/main/java/dev/loki/lovisual/util/entity/EagleUtil.java
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/*
|
||||
* 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.entity;
|
||||
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.entity.EntityDimensions;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.events.impl.movement.MovementInputEvent;
|
||||
import dev.loki.lovisual.render.helpers.util.TickDelta;
|
||||
|
||||
public enum EagleUtil {
|
||||
;
|
||||
private static final double STEP_HEIGHT = 0.5;
|
||||
private static final double MIN_INPUT_LENGTH_SQUARED = 1.0E-6;
|
||||
private static final double MIN_AXIS_COMPONENT = 0.18;
|
||||
private static final double MAX_RESCUE_LOOKAHEAD = 0.42;
|
||||
private static final double MIN_TICK_DELTA_LEAD = 0.25;
|
||||
private static final double CENTER_DEAD_ANGLE_COS = 0.34;
|
||||
private static final int DIAGONAL_RESCUE_TICKS = 2;
|
||||
|
||||
public static EdgeCheck checkEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
|
||||
return checkEdge(player, event, edgeDistance, TickDelta.get());
|
||||
}
|
||||
|
||||
public static EdgeCheck checkEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance, double tickDelta) {
|
||||
if (player == null || event == null) {
|
||||
return EdgeCheck.empty();
|
||||
}
|
||||
|
||||
Vec3 inputDirection = movementInputToWorld(player, event);
|
||||
if (inputDirection.lengthSqr() < MIN_INPUT_LENGTH_SQUARED) {
|
||||
return EdgeCheck.empty();
|
||||
}
|
||||
|
||||
Vec3 movementDirection = inputDirection.normalize();
|
||||
Vec3 horizontalVelocity = new Vec3(player.getDeltaMovement().x, 0.0, player.getDeltaMovement().z);
|
||||
double velocityLength = horizontalVelocity.horizontalDistance();
|
||||
double distance = Math.max(0.0, edgeDistance);
|
||||
double lookAhead = Math.min(MAX_RESCUE_LOOKAHEAD, distance + velocityLength);
|
||||
double firstLead = Math.max(MIN_TICK_DELTA_LEAD, clamp01(tickDelta));
|
||||
Vec3 partialPos = player.position().add(horizontalVelocity.scale(firstLead));
|
||||
Vec3 nextPartialPos = player.position().add(horizontalVelocity.scale(firstLead + 0.5));
|
||||
|
||||
boolean closeToEdge = wouldFallAlong(player, player.position(), movementDirection, distance)
|
||||
|| wouldFallAlong(player, partialPos, movementDirection, distance)
|
||||
|| wouldFallAlong(player, nextPartialPos, movementDirection, distance);
|
||||
|
||||
boolean diagonalRescue = isDiagonal(event)
|
||||
&& wouldDiagonalFall(player, movementDirection, horizontalVelocity, Math.max(distance, lookAhead), firstLead);
|
||||
|
||||
return new EdgeCheck(closeToEdge || diagonalRescue, diagonalRescue, movementDirection);
|
||||
}
|
||||
|
||||
public static boolean isCloseToEdge(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
|
||||
return checkEdge(player, event, edgeDistance).closeToEdge();
|
||||
}
|
||||
|
||||
public static boolean shouldDiagonalRescue(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
|
||||
return checkEdge(player, event, edgeDistance).diagonalRescue();
|
||||
}
|
||||
|
||||
public static double horizontalSpeed(LocalPlayer player) {
|
||||
if (player == null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Vec3 velocity = player.getDeltaMovement();
|
||||
return Math.hypot(velocity.x, velocity.z);
|
||||
}
|
||||
|
||||
public static Vec3 movementInputToWorld(LocalPlayer player, MovementInputEvent event) {
|
||||
if (player == null || event == null) {
|
||||
return Vec3.ZERO;
|
||||
}
|
||||
|
||||
float forward = impulse(event.isForward(), event.isBackward());
|
||||
float sideways = impulse(event.isRight(), event.isLeft());
|
||||
if (forward == 0.0f && sideways == 0.0f) {
|
||||
return Vec3.ZERO;
|
||||
}
|
||||
|
||||
double rad = Math.toRadians(player.getYRot());
|
||||
double sin = Math.sin(rad);
|
||||
double cos = Math.cos(rad);
|
||||
double x = sideways * cos - forward * sin;
|
||||
double z = forward * cos + sideways * sin;
|
||||
return new Vec3(x, 0.0, z);
|
||||
}
|
||||
|
||||
public static boolean wouldBeCloseToFallOff(LocalPlayer player, Vec3 position) {
|
||||
if (player == null || position == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EntityDimensions dimensions = player.getDimensions(player.getPose());
|
||||
AABB hitbox = dimensions.makeBoundingBox(position)
|
||||
.inflate(-0.05, 0.0, -0.05)
|
||||
.move(0.0, player.fallDistance - STEP_HEIGHT, 0.0);
|
||||
return player.level().noCollision(player, hitbox);
|
||||
}
|
||||
|
||||
private static boolean wouldDiagonalFall(
|
||||
LocalPlayer player,
|
||||
Vec3 movementDirection,
|
||||
Vec3 horizontalVelocity,
|
||||
double distance,
|
||||
double firstLead
|
||||
) {
|
||||
Vec3 base = player.position().add(horizontalVelocity.scale(firstLead));
|
||||
Vec3 predicted = player.position().add(horizontalVelocity.scale(firstLead + 0.5));
|
||||
Vec3 predictedSecond = player.position().add(horizontalVelocity.scale(firstLead + 1.0));
|
||||
|
||||
if (wouldFallAlong(player, base, movementDirection, distance)
|
||||
|| wouldFallAlong(player, predicted, movementDirection, distance)
|
||||
|| wouldFallAlong(player, predictedSecond, movementDirection, distance)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Vec3 xAxis = Math.abs(movementDirection.x) > MIN_AXIS_COMPONENT
|
||||
? new Vec3(Math.signum(movementDirection.x), 0.0, 0.0)
|
||||
: Vec3.ZERO;
|
||||
Vec3 zAxis = Math.abs(movementDirection.z) > MIN_AXIS_COMPONENT
|
||||
? new Vec3(0.0, 0.0, Math.signum(movementDirection.z))
|
||||
: Vec3.ZERO;
|
||||
|
||||
return (xAxis != Vec3.ZERO && wouldFallAlong(player, predicted, xAxis, distance))
|
||||
|| (zAxis != Vec3.ZERO && wouldFallAlong(player, predicted, zAxis, distance))
|
||||
|| (xAxis != Vec3.ZERO && zAxis != Vec3.ZERO
|
||||
&& wouldFallAlong(player, predicted, xAxis.add(zAxis).normalize(), distance));
|
||||
}
|
||||
|
||||
private static boolean wouldFallAlong(LocalPlayer player, Vec3 base, Vec3 direction, double distance) {
|
||||
if (player == null || base == null || direction == null || direction.lengthSqr() < MIN_INPUT_LENGTH_SQUARED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Vec3 normalized = direction.normalize();
|
||||
double clampedDistance = Math.max(0.0, distance);
|
||||
if (clampedDistance <= 1.0E-7) {
|
||||
return wouldBeCloseToFallOff(player, base);
|
||||
}
|
||||
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
double scale = clampedDistance * i / 3.0;
|
||||
Vec3 checkedPos = base.add(normalized.x * scale, 0.0, normalized.z * scale);
|
||||
if (wouldBeCloseToFallOff(player, checkedPos)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isDiagonal(MovementInputEvent event) {
|
||||
return event != null
|
||||
&& event.isForward() != event.isBackward()
|
||||
&& event.isLeft() != event.isRight();
|
||||
}
|
||||
|
||||
private static float impulse(boolean positive, boolean negative) {
|
||||
if (positive == negative) return 0.0f;
|
||||
return positive ? 1.0f : -1.0f;
|
||||
}
|
||||
|
||||
private static double clamp01(double value) {
|
||||
if (Double.isNaN(value)) return 0.0;
|
||||
return Math.max(0.0, Math.min(1.0, value));
|
||||
}
|
||||
|
||||
public enum RecoveryMode {
|
||||
STOP,
|
||||
INVERT,
|
||||
CENTER
|
||||
}
|
||||
|
||||
public static final class EdgeRecovery {
|
||||
private Vec3 center;
|
||||
private int overwriteTicks;
|
||||
private int sneakTicks;
|
||||
|
||||
private static Vec3 blockBottomCenter(LocalPlayer player) {
|
||||
return new Vec3(
|
||||
player.blockPosition().getX() + 0.5,
|
||||
player.blockPosition().getY(),
|
||||
player.blockPosition().getZ() + 0.5
|
||||
);
|
||||
}
|
||||
|
||||
private static double horizontalDistanceSquared(Vec3 a, Vec3 b) {
|
||||
double x = a.x - b.x;
|
||||
double z = a.z - b.z;
|
||||
return x * x + z * z;
|
||||
}
|
||||
|
||||
public boolean handleDiagonalRescue(LocalPlayer player, MovementInputEvent event, double edgeDistance) {
|
||||
if (player == null || event == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateSafeCenter(player);
|
||||
|
||||
EdgeCheck edgeCheck = checkEdge(player, event, edgeDistance);
|
||||
if (edgeCheck.diagonalRescue() && shouldOverwriteInput(player)) {
|
||||
overwriteTicks = Math.max(overwriteTicks, DIAGONAL_RESCUE_TICKS);
|
||||
}
|
||||
|
||||
if (overwriteTicks <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
overwriteTicks--;
|
||||
applyInputTowardsCenter(player, event);
|
||||
event.setJump(false);
|
||||
event.setSneak(true);
|
||||
event.setSprint(false);
|
||||
|
||||
updateSafeCenter(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean handleOnEdge(
|
||||
LocalPlayer player,
|
||||
MovementInputEvent event,
|
||||
double edgeDistance,
|
||||
RecoveryMode mode,
|
||||
int keepTicks,
|
||||
int requestedSneakTicks,
|
||||
boolean jump
|
||||
) {
|
||||
if (player == null || event == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateSafeCenter(player);
|
||||
|
||||
double distance = Math.min(horizontalSpeed(player), Math.max(0.0, edgeDistance));
|
||||
EdgeCheck edgeCheck = checkEdge(player, event, distance);
|
||||
if (edgeCheck.closeToEdge() && shouldOverwriteInput(player)) {
|
||||
overwriteTicks = Math.max(overwriteTicks, Math.max(1, keepTicks));
|
||||
sneakTicks = Math.max(sneakTicks, Math.max(0, requestedSneakTicks));
|
||||
}
|
||||
|
||||
boolean applied = false;
|
||||
if (overwriteTicks > 0) {
|
||||
overwriteTicks--;
|
||||
applyRecoveryInput(player, event, mode);
|
||||
event.setJump(jump);
|
||||
event.setSprint(false);
|
||||
applied = true;
|
||||
}
|
||||
|
||||
if (sneakTicks > 0) {
|
||||
sneakTicks--;
|
||||
event.setSneak(true);
|
||||
applied = true;
|
||||
}
|
||||
|
||||
updateSafeCenter(player);
|
||||
return applied;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
center = null;
|
||||
overwriteTicks = 0;
|
||||
sneakTicks = 0;
|
||||
}
|
||||
|
||||
private boolean shouldOverwriteInput(LocalPlayer player) {
|
||||
Vec3 center = this.center;
|
||||
if (center == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Vec3 horizontalVelocity = new Vec3(player.getDeltaMovement().x, 0.0, player.getDeltaMovement().z);
|
||||
if (horizontalVelocity.horizontalDistanceSqr() <= 0.02 * 0.02) {
|
||||
return true;
|
||||
}
|
||||
|
||||
double currentDistance = horizontalDistanceSquared(center, player.position());
|
||||
double nextDistance = horizontalDistanceSquared(center, player.position().add(horizontalVelocity));
|
||||
return nextDistance > currentDistance;
|
||||
}
|
||||
|
||||
private void applyRecoveryInput(LocalPlayer player, MovementInputEvent event, RecoveryMode mode) {
|
||||
RecoveryMode resolvedMode = mode != null ? mode : RecoveryMode.CENTER;
|
||||
if (resolvedMode == RecoveryMode.INVERT) {
|
||||
boolean forward = event.isForward();
|
||||
boolean backward = event.isBackward();
|
||||
boolean left = event.isLeft();
|
||||
boolean right = event.isRight();
|
||||
event.setForward(backward);
|
||||
event.setBackward(forward);
|
||||
event.setLeft(right);
|
||||
event.setRight(left);
|
||||
event.setJump(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedMode == RecoveryMode.STOP && horizontalSpeed(player) <= 0.05) {
|
||||
event.setForward(false);
|
||||
event.setBackward(false);
|
||||
event.setLeft(false);
|
||||
event.setRight(false);
|
||||
event.setJump(false);
|
||||
return;
|
||||
}
|
||||
|
||||
applyInputTowardsCenter(player, event);
|
||||
}
|
||||
|
||||
private void applyInputTowardsCenter(LocalPlayer player, MovementInputEvent event) {
|
||||
Vec3 center = this.center != null ? this.center : blockBottomCenter(player);
|
||||
Vec3 toCenter = center.subtract(player.position());
|
||||
Vec3 horizontal = new Vec3(toCenter.x, 0.0, toCenter.z);
|
||||
|
||||
if (horizontal.lengthSqr() < 0.015 * 0.015) {
|
||||
event.setForward(false);
|
||||
event.setBackward(false);
|
||||
event.setLeft(false);
|
||||
event.setRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Vec3 direction = horizontal.normalize();
|
||||
double rad = Math.toRadians(player.getYRot());
|
||||
double sin = Math.sin(rad);
|
||||
double cos = Math.cos(rad);
|
||||
double localForward = direction.z * cos - direction.x * sin;
|
||||
double localSideways = direction.x * cos + direction.z * sin;
|
||||
|
||||
boolean forward = localForward > CENTER_DEAD_ANGLE_COS;
|
||||
boolean backward = localForward < -CENTER_DEAD_ANGLE_COS;
|
||||
boolean left = localSideways < -CENTER_DEAD_ANGLE_COS;
|
||||
boolean right = localSideways > CENTER_DEAD_ANGLE_COS;
|
||||
|
||||
if (!forward && !backward && !left && !right) {
|
||||
if (Math.abs(localForward) >= Math.abs(localSideways)) {
|
||||
forward = localForward > 0.0;
|
||||
backward = localForward < 0.0;
|
||||
} else {
|
||||
left = localSideways < 0.0;
|
||||
right = localSideways > 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
event.setForward(forward);
|
||||
event.setBackward(backward);
|
||||
event.setLeft(left);
|
||||
event.setRight(right);
|
||||
}
|
||||
|
||||
private void updateSafeCenter(LocalPlayer player) {
|
||||
Vec3 blockCenter = blockBottomCenter(player);
|
||||
if (!wouldBeCloseToFallOff(player, blockCenter)) {
|
||||
center = blockCenter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record EdgeCheck(boolean closeToEdge, boolean diagonalRescue, Vec3 movementDirection) {
|
||||
private static EdgeCheck empty() {
|
||||
return new EdgeCheck(false, false, Vec3.ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.entity;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.RemotePlayer;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class FreecamEntity extends RemotePlayer {
|
||||
private static final int FREECAM_ID_BASE = -1_100_000;
|
||||
private static final int FREECAM_ID_RANGE = 10_000;
|
||||
private static int nextFreecamEntityId = FREECAM_ID_BASE;
|
||||
|
||||
public FreecamEntity(ClientLevel world) {
|
||||
super(world, new GameProfile(UUID.randomUUID(), "freecam"));
|
||||
this.setId(allocateFreecamEntityId(world));
|
||||
this.noPhysics = true;
|
||||
this.setInvisible(true);
|
||||
this.setInvulnerable(true);
|
||||
}
|
||||
|
||||
private static int allocateFreecamEntityId(ClientLevel world) {
|
||||
for (int i = 0; i < FREECAM_ID_RANGE; i++) {
|
||||
int candidate = nextFreecamEntityId--;
|
||||
if (nextFreecamEntityId < FREECAM_ID_BASE - FREECAM_ID_RANGE) {
|
||||
nextFreecamEntityId = FREECAM_ID_BASE;
|
||||
}
|
||||
if (world.getEntity(candidate) == null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return FREECAM_ID_BASE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldShowName() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldRenderAtSqrDistance(double distance) {
|
||||
return false; // якорь: в мире может существовать, но не рисуется
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* 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.entity.simulation;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.tags.FluidTags;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
|
||||
import net.minecraft.world.level.block.LilyPadBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraft.world.phys.shapes.BooleanOp;
|
||||
import net.minecraft.world.phys.shapes.Shapes;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
import dev.loki.lovisual.events.meta.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
|
||||
public final class BoatSimulationCache {
|
||||
|
||||
public static final BoatSimulationCache INSTANCE = new BoatSimulationCache();
|
||||
private static final double BOAT_GRAVITY = 0.04;
|
||||
|
||||
private final Map<AbstractBoat, SimulatedBoatCache> cache = new WeakHashMap<>();
|
||||
|
||||
private BoatSimulationCache() {
|
||||
}
|
||||
|
||||
public static SimulatedBoatCache getSimulation(AbstractBoat boat) {
|
||||
return INSTANCE.cache.computeIfAbsent(boat, SimulatedBoatCache::new);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onGameTick(GameTickEvent event) {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
private enum Location {
|
||||
IN_WATER,
|
||||
UNDER_WATER,
|
||||
UNDER_FLOWING_WATER,
|
||||
ON_LAND,
|
||||
IN_AIR
|
||||
}
|
||||
|
||||
public static final class SimulatedBoatCache {
|
||||
private final SimulatedBoat simulator;
|
||||
private final List<BoatSnapshot> snapshots = new ArrayList<>();
|
||||
private int simulatedTicks;
|
||||
|
||||
private SimulatedBoatCache(AbstractBoat boat) {
|
||||
this.simulator = new SimulatedBoat(boat);
|
||||
this.snapshots.add(new BoatSnapshot(simulator.pos, simulator.yaw));
|
||||
}
|
||||
|
||||
public BoatSnapshot getSnapshotAt(int ticks) {
|
||||
while (simulatedTicks < ticks) {
|
||||
simulator.tick();
|
||||
snapshots.add(new BoatSnapshot(simulator.pos, simulator.yaw));
|
||||
simulatedTicks++;
|
||||
}
|
||||
return snapshots.get(ticks);
|
||||
}
|
||||
}
|
||||
|
||||
public record BoatSnapshot(Vec3 pos, float yaw) {
|
||||
}
|
||||
|
||||
private static final class SimulatedBoat {
|
||||
private final AbstractBoat boat;
|
||||
private final float yaw;
|
||||
private Vec3 pos;
|
||||
private Vec3 velocity;
|
||||
private AABB boundingBox;
|
||||
private float yawVelocity;
|
||||
private double waterLevel;
|
||||
private float nearbySlipperiness;
|
||||
private Location location;
|
||||
private Location lastLocation;
|
||||
private double fallVelocity;
|
||||
|
||||
private SimulatedBoat(AbstractBoat boat) {
|
||||
this.boat = boat;
|
||||
this.pos = boat.position();
|
||||
this.velocity = boat.getDeltaMovement();
|
||||
this.boundingBox = boat.getBoundingBox();
|
||||
this.yaw = boat.getYRot();
|
||||
this.yawVelocity = 0.0f;
|
||||
this.location = checkLocation();
|
||||
this.lastLocation = location;
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
lastLocation = location;
|
||||
location = checkLocation();
|
||||
updateVelocity();
|
||||
move();
|
||||
}
|
||||
|
||||
private void move() {
|
||||
Vec3 adjusted = Entity.collideBoundingBox(boat, velocity, boundingBox, boat.level(), List.of());
|
||||
pos = pos.add(adjusted);
|
||||
boundingBox = boat.getBoundingBox().move(pos.subtract(boat.position()));
|
||||
velocity = adjusted;
|
||||
}
|
||||
|
||||
private Location checkLocation() {
|
||||
Location underWater = getUnderWaterLocation();
|
||||
if (underWater != null) {
|
||||
waterLevel = boundingBox.maxY;
|
||||
return underWater;
|
||||
}
|
||||
if (checkBoatInWater()) {
|
||||
return Location.IN_WATER;
|
||||
}
|
||||
float slipperiness = getNearbySlipperiness();
|
||||
if (slipperiness > 0.0f) {
|
||||
nearbySlipperiness = slipperiness;
|
||||
return Location.ON_LAND;
|
||||
}
|
||||
return Location.IN_AIR;
|
||||
}
|
||||
|
||||
private void updateVelocity() {
|
||||
double gravity = -boat.getGravity();
|
||||
double buoyancy = 0.0;
|
||||
float drag = 0.05f;
|
||||
|
||||
if (lastLocation == Location.IN_AIR && location != Location.IN_AIR && location != Location.ON_LAND) {
|
||||
waterLevel = pos.y + boat.getBbHeight();
|
||||
double waterHeightBelow = getWaterHeightBelow() - boat.getBbHeight() + 0.101;
|
||||
AABB movedBox = boundingBox.move(0.0, waterHeightBelow - pos.y, 0.0);
|
||||
if (boat.level().noCollision(boat, movedBox)) {
|
||||
pos = new Vec3(pos.x, waterHeightBelow, pos.z);
|
||||
boundingBox = movedBox;
|
||||
velocity = new Vec3(velocity.x, 0.0, velocity.z);
|
||||
fallVelocity = 0.0;
|
||||
}
|
||||
location = Location.IN_WATER;
|
||||
} else {
|
||||
if (location == Location.IN_WATER) {
|
||||
buoyancy = (waterLevel - pos.y) / boat.getBbHeight();
|
||||
drag = 0.9f;
|
||||
} else if (location == Location.UNDER_FLOWING_WATER) {
|
||||
gravity = -7.0E-4;
|
||||
drag = 0.9f;
|
||||
} else if (location == Location.UNDER_WATER) {
|
||||
buoyancy = 0.01f;
|
||||
drag = 0.45f;
|
||||
} else if (location == Location.IN_AIR) {
|
||||
drag = 0.9f;
|
||||
} else if (location == Location.ON_LAND) {
|
||||
drag = nearbySlipperiness;
|
||||
if (boat.getControllingPassenger() instanceof Player) {
|
||||
nearbySlipperiness /= 2.0f;
|
||||
}
|
||||
}
|
||||
|
||||
velocity = new Vec3(velocity.x * drag, velocity.y + gravity, velocity.z * drag);
|
||||
yawVelocity *= drag;
|
||||
if (buoyancy > 0.0) {
|
||||
velocity = new Vec3(
|
||||
velocity.x,
|
||||
(velocity.y + buoyancy * (BOAT_GRAVITY / 0.65)) * 0.75,
|
||||
velocity.z
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private float getWaterHeightBelow() {
|
||||
AABB box = boundingBox;
|
||||
int minX = Mth.floor(box.minX);
|
||||
int maxX = Mth.ceil(box.maxX);
|
||||
int minY = Mth.floor(box.maxY);
|
||||
int maxY = Mth.ceil(box.maxY - fallVelocity);
|
||||
int minZ = Mth.floor(box.minZ);
|
||||
int maxZ = Mth.ceil(box.maxZ);
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
float fluidHeight = 0.0f;
|
||||
for (int x = minX; x < maxX; x++) {
|
||||
for (int z = minZ; z < maxZ; z++) {
|
||||
mutable.set(x, y, z);
|
||||
FluidState fluidState = boat.level().getFluidState(mutable);
|
||||
if (fluidState.is(FluidTags.WATER)) {
|
||||
fluidHeight = Math.max(fluidHeight, fluidState.getHeight(boat.level(), mutable));
|
||||
}
|
||||
if (fluidHeight >= 1.0f) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fluidHeight < 1.0f) {
|
||||
return mutable.getY() + fluidHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return maxY + 1.0f;
|
||||
}
|
||||
|
||||
private float getNearbySlipperiness() {
|
||||
AABB box = boundingBox;
|
||||
AABB sample = new AABB(box.minX, box.minY - 0.001, box.minZ, box.maxX, box.minY, box.maxZ);
|
||||
int minX = Mth.floor(sample.minX) - 1;
|
||||
int maxX = Mth.ceil(sample.maxX) + 1;
|
||||
int minY = Mth.floor(sample.minY) - 1;
|
||||
int maxY = Mth.ceil(sample.maxY) + 1;
|
||||
int minZ = Mth.floor(sample.minZ) - 1;
|
||||
int maxZ = Mth.ceil(sample.maxZ) + 1;
|
||||
VoxelShape shape = Shapes.create(sample);
|
||||
float slipperiness = 0.0f;
|
||||
int count = 0;
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int x = minX; x < maxX; x++) {
|
||||
for (int z = minZ; z < maxZ; z++) {
|
||||
int edge = (x != minX && x != maxX - 1 ? 0 : 1) + (z != minZ && z != maxZ - 1 ? 0 : 1);
|
||||
if (edge == 2) {
|
||||
continue;
|
||||
}
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
if (edge <= 0 || y != minY && y != maxY - 1) {
|
||||
mutable.set(x, y, z);
|
||||
BlockState state = boat.level().getBlockState(mutable);
|
||||
if (!(state.getBlock() instanceof LilyPadBlock)
|
||||
&& Shapes.joinIsNotEmpty(state.getCollisionShape(boat.level(), mutable).move(mutable), shape, BooleanOp.AND)) {
|
||||
slipperiness += state.getBlock().getFriction();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count == 0 ? 0.0f : slipperiness / count;
|
||||
}
|
||||
|
||||
private boolean checkBoatInWater() {
|
||||
AABB box = boundingBox;
|
||||
int minX = Mth.floor(box.minX);
|
||||
int maxX = Mth.ceil(box.maxX);
|
||||
int minY = Mth.floor(box.minY);
|
||||
int maxY = Mth.ceil(box.minY + 0.001);
|
||||
int minZ = Mth.floor(box.minZ);
|
||||
int maxZ = Mth.ceil(box.maxZ);
|
||||
boolean inWater = false;
|
||||
waterLevel = -Double.MAX_VALUE;
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int x = minX; x < maxX; x++) {
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
for (int z = minZ; z < maxZ; z++) {
|
||||
mutable.set(x, y, z);
|
||||
FluidState fluidState = boat.level().getFluidState(mutable);
|
||||
if (fluidState.is(FluidTags.WATER)) {
|
||||
float height = y + fluidState.getHeight(boat.level(), mutable);
|
||||
waterLevel = Math.max(height, waterLevel);
|
||||
inWater |= box.minY < height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return inWater;
|
||||
}
|
||||
|
||||
private Location getUnderWaterLocation() {
|
||||
AABB box = boundingBox;
|
||||
double top = box.maxY + 0.001;
|
||||
int minX = Mth.floor(box.minX);
|
||||
int maxX = Mth.ceil(box.maxX);
|
||||
int minY = Mth.floor(box.maxY);
|
||||
int maxY = Mth.ceil(top);
|
||||
int minZ = Mth.floor(box.minZ);
|
||||
int maxZ = Mth.ceil(box.maxZ);
|
||||
boolean still = false;
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
|
||||
for (int x = minX; x < maxX; x++) {
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
for (int z = minZ; z < maxZ; z++) {
|
||||
mutable.set(x, y, z);
|
||||
FluidState fluidState = boat.level().getFluidState(mutable);
|
||||
if (fluidState.is(FluidTags.WATER) && top < mutable.getY() + fluidState.getHeight(boat.level(), mutable)) {
|
||||
if (!fluidState.isSource()) {
|
||||
return Location.UNDER_FLOWING_WATER;
|
||||
}
|
||||
still = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return still ? Location.UNDER_WATER : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* 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.entity.simulation;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.util.player.simulation.PlayerSimulationCache;
|
||||
|
||||
/**
|
||||
* Predicts passengers by following the predicted vehicle position and rotating
|
||||
* the current passenger offset by the vehicle yaw delta.
|
||||
*/
|
||||
public enum MountedEntityPrediction {
|
||||
;
|
||||
|
||||
public static Vec3 predictMountedPosition(Entity entity, double ticks) {
|
||||
if (entity == null) {
|
||||
return Vec3.ZERO;
|
||||
}
|
||||
|
||||
Entity vehicle = entity.getVehicle();
|
||||
if (vehicle == null) {
|
||||
return entity.position();
|
||||
}
|
||||
|
||||
Vec3 predictedVehiclePos = predictVehiclePosition(vehicle, ticks);
|
||||
float currentVehicleYaw = vehicle.getYRot();
|
||||
float predictedVehicleYaw = predictVehicleYaw(vehicle, ticks);
|
||||
|
||||
Vec3 currentOffset = entity.position().subtract(vehicle.position());
|
||||
Vec3 localOffset = rotateY(currentOffset, -currentVehicleYaw);
|
||||
Vec3 rotatedOffset = rotateY(localOffset, predictedVehicleYaw);
|
||||
|
||||
return predictedVehiclePos.add(rotatedOffset);
|
||||
}
|
||||
|
||||
private static Vec3 predictVehiclePosition(Entity vehicle, double ticks) {
|
||||
if (vehicle.isPassenger()) {
|
||||
return predictMountedPosition(vehicle, ticks);
|
||||
}
|
||||
|
||||
if (vehicle instanceof AbstractBoat boat) {
|
||||
return BoatSimulationCache.getSimulation(boat).getSnapshotAt((int) Math.ceil(ticks)).pos();
|
||||
}
|
||||
|
||||
if (vehicle instanceof Player player) {
|
||||
return PlayerSimulationCache.getSimulationForOtherPlayers(player).getSnapshotAt((int) Math.ceil(ticks)).pos();
|
||||
}
|
||||
|
||||
return PositionPredictor.predictEntityPos(vehicle, ticks);
|
||||
}
|
||||
|
||||
private static float predictVehicleYaw(Entity vehicle, double ticks) {
|
||||
if (vehicle instanceof AbstractBoat boat) {
|
||||
return BoatSimulationCache.getSimulation(boat).getSnapshotAt((int) Math.ceil(ticks)).yaw();
|
||||
}
|
||||
|
||||
float yaw = vehicle.getYRot();
|
||||
float yawDelta = Mth.wrapDegrees(yaw - vehicle.yRotO);
|
||||
return yaw + yawDelta * (float) ticks;
|
||||
}
|
||||
|
||||
private static Vec3 rotateY(Vec3 vec, float yawDegrees) {
|
||||
double rad = Math.toRadians(yawDegrees);
|
||||
double sin = Math.sin(rad);
|
||||
double cos = Math.cos(rad);
|
||||
double x = vec.x * cos - vec.z * sin;
|
||||
double z = vec.x * sin + vec.z * cos;
|
||||
return new Vec3(x, vec.y, z);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.util.entity.simulation;
|
||||
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public enum PositionExtrapolation {
|
||||
;
|
||||
|
||||
public static Extrapolation getBestForEntity(Entity entity) {
|
||||
return new Extrapolation(entity);
|
||||
}
|
||||
|
||||
public static final class Extrapolation {
|
||||
private final Entity entity;
|
||||
|
||||
private Extrapolation(Entity entity) {
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
public Vec3 getPositionInTicks(double ticks) {
|
||||
return PositionPredictor.predictEntityPos(entity, ticks);
|
||||
}
|
||||
|
||||
public Vec3 getEyePositionInTicks(double ticks) {
|
||||
if (entity instanceof LivingEntity living) {
|
||||
return PositionPredictor.predictEyePos(living, ticks);
|
||||
}
|
||||
return getPositionInTicks(ticks);
|
||||
}
|
||||
|
||||
public AABB getBoxInTicks(double ticks) {
|
||||
return PositionPredictor.predictBox(entity, ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* 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.entity.simulation;
|
||||
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
|
||||
/**
|
||||
* Simple position extrapolation based on current velocity.
|
||||
*/
|
||||
public enum PositionPredictor {
|
||||
;
|
||||
|
||||
private static final Map<Entity, State> STATES = new WeakHashMap<>();
|
||||
|
||||
public static Vec3 predictEntityPos(Entity entity, double ticks) {
|
||||
if (entity == null || ticks <= 0) {
|
||||
return entity != null ? entity.position() : Vec3.ZERO;
|
||||
}
|
||||
State state = updateState(entity);
|
||||
Vec3 base = pickBaseMovement(entity, state);
|
||||
Vec3 accel = state.prevMovement != null
|
||||
? state.lastMovement.subtract(state.prevMovement)
|
||||
: Vec3.ZERO;
|
||||
|
||||
double t = ticks;
|
||||
Vec3 predicted = state.lastPos.add(base.scale(t));
|
||||
if (state.prevMovement != null) {
|
||||
predicted = predicted.add(accel.scale(0.5 * t * t));
|
||||
}
|
||||
return predicted;
|
||||
}
|
||||
|
||||
public static Vec3 predictEyePos(LivingEntity entity, double ticks) {
|
||||
if (entity == null) return Vec3.ZERO;
|
||||
Vec3 pos = predictEntityPos(entity, ticks);
|
||||
Vec3 eyeOffset = entity.getEyePosition().subtract(entity.position());
|
||||
return pos.add(eyeOffset);
|
||||
}
|
||||
|
||||
public static AABB predictBox(Entity entity, double ticks) {
|
||||
if (entity == null) return new AABB(0, 0, 0, 0, 0, 0);
|
||||
Vec3 predicted = predictEntityPos(entity, ticks);
|
||||
Vec3 current = entity.position();
|
||||
Vec3 delta = predicted.subtract(current);
|
||||
return entity.getBoundingBox().move(delta);
|
||||
}
|
||||
|
||||
private static State updateState(Entity entity) {
|
||||
State state = STATES.get(entity);
|
||||
if (state == null) {
|
||||
state = new State();
|
||||
state.ref = new WeakReference<>(entity);
|
||||
STATES.put(entity, state);
|
||||
}
|
||||
|
||||
int age = entity.tickCount;
|
||||
if (state.lastAge != age) {
|
||||
state.lastAge = age;
|
||||
state.lastPos = entity.position();
|
||||
state.prevMovement = state.lastMovement;
|
||||
state.lastMovement = entity.getKnownMovement();
|
||||
state.lastVelocity = entity.getDeltaMovement();
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static Vec3 pickBaseMovement(Entity entity, State state) {
|
||||
Vec3 movement = state.lastMovement != null ? state.lastMovement : entity.getKnownMovement();
|
||||
Vec3 velocity = state.lastVelocity != null ? state.lastVelocity : entity.getDeltaMovement();
|
||||
|
||||
double moveLen = movement.lengthSqr();
|
||||
double velLen = velocity.lengthSqr();
|
||||
if (moveLen < 1.0e-6 && velLen > moveLen) {
|
||||
return velocity;
|
||||
}
|
||||
return movement;
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private WeakReference<Entity> ref;
|
||||
private int lastAge = Integer.MIN_VALUE;
|
||||
private Vec3 lastPos = Vec3.ZERO;
|
||||
private Vec3 lastMovement = Vec3.ZERO;
|
||||
private Vec3 prevMovement;
|
||||
private Vec3 lastVelocity = Vec3.ZERO;
|
||||
}
|
||||
}
|
||||
244
mod/src/main/java/dev/loki/lovisual/util/input/KeyManager.java
Normal file
244
mod/src/main/java/dev/loki/lovisual/util/input/KeyManager.java
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
* 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.input;
|
||||
|
||||
import dev.loki.lovisual.util.screen.ClientScreen;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
|
||||
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import dev.loki.lovisual.events.Event;
|
||||
|
||||
public enum KeyManager {
|
||||
;
|
||||
|
||||
/**
|
||||
* func -> combos
|
||||
*/
|
||||
private static final Map<String, Set<Set<Integer>>> comboBinds = new HashMap<>();
|
||||
|
||||
/**
|
||||
* combo -> func(s); allow multiple modules to share the same combo
|
||||
*/
|
||||
private static final Map<Set<Integer>, Set<String>> reverseLookup = new HashMap<>();
|
||||
|
||||
/**
|
||||
* per-function debounce state for wasPressed
|
||||
*/
|
||||
private static final Map<String, Boolean> prevFuncState = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Event-backed state for mouse buttons and scancode-only keys.
|
||||
*/
|
||||
private static final Map<Integer, Boolean> liveInputState = new HashMap<>();
|
||||
|
||||
// ============================================================
|
||||
// Registration / removal
|
||||
// ============================================================
|
||||
|
||||
public static void registerCombo(String func, String comboString) {
|
||||
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
|
||||
if (combo.isEmpty()) return;
|
||||
|
||||
comboBinds.computeIfAbsent(func, k -> new HashSet<>()).add(combo);
|
||||
reverseLookup.computeIfAbsent(combo, k -> new HashSet<>()).add(func);
|
||||
|
||||
DebugLog.info("[KeyManager] Registered %s = %s", func, comboString);
|
||||
}
|
||||
|
||||
public static void unregisterCombo(String func, String comboString) {
|
||||
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
|
||||
if (combo.isEmpty()) return;
|
||||
|
||||
if (!comboBinds.containsKey(func)) return;
|
||||
|
||||
comboBinds.get(func).remove(combo);
|
||||
Set<String> funcs = reverseLookup.get(combo);
|
||||
if (funcs != null) {
|
||||
funcs.remove(func);
|
||||
if (funcs.isEmpty()) reverseLookup.remove(combo);
|
||||
}
|
||||
|
||||
if (comboBinds.get(func).isEmpty()) {
|
||||
comboBinds.remove(func);
|
||||
}
|
||||
|
||||
DebugLog.info("[KeyManager] Unregistered %s = %s", func, comboString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all combos bound to a function.
|
||||
*/
|
||||
public static void unregisterAll(String func) {
|
||||
if (!comboBinds.containsKey(func)) return;
|
||||
|
||||
for (Set<Integer> combo : comboBinds.get(func)) {
|
||||
Set<String> funcs = reverseLookup.get(combo);
|
||||
if (funcs != null) {
|
||||
funcs.remove(func);
|
||||
if (funcs.isEmpty()) reverseLookup.remove(combo);
|
||||
}
|
||||
}
|
||||
|
||||
comboBinds.remove(func);
|
||||
prevFuncState.remove(func);
|
||||
|
||||
DebugLog.info("[KeyManager] Unregistered ALL combos for %s", func);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event state bridge
|
||||
// ============================================================
|
||||
|
||||
public static void handleKeyEvent(int key, int scancode, int action) {
|
||||
boolean pressed = action != GLFW.GLFW_RELEASE;
|
||||
|
||||
if (key != GLFW.GLFW_KEY_UNKNOWN) {
|
||||
liveInputState.put(key, pressed);
|
||||
}
|
||||
|
||||
if (scancode >= 0) {
|
||||
liveInputState.put(KeyUtil.scancodeToCode(scancode), pressed);
|
||||
}
|
||||
}
|
||||
|
||||
public static void handleMouseButtonEvent(int button, int action) {
|
||||
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
|
||||
liveInputState.put(KeyUtil.mouseButtonToCode(button), action != GLFW.GLFW_RELEASE);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* True once when combo transitions from not-pressed to pressed for this func.
|
||||
*/
|
||||
public static boolean wasPressed(String func) {
|
||||
if (ClickGuiSearch.isActive()) return false;
|
||||
if (!comboBinds.containsKey(func)) return false;
|
||||
|
||||
boolean anyPressed = false;
|
||||
for (Set<Integer> combo : comboBinds.get(func)) {
|
||||
boolean allPressed = true;
|
||||
for (int key : combo) {
|
||||
if (!isKeyPressed(key, false)) {
|
||||
allPressed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPressed) {
|
||||
anyPressed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
boolean prev = prevFuncState.getOrDefault(func, false);
|
||||
if (anyPressed && !prev) {
|
||||
prevFuncState.put(func, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!anyPressed) {
|
||||
prevFuncState.put(func, false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any combo for the function is held down.
|
||||
*/
|
||||
public static boolean isHeld(String func) {
|
||||
return isHeldInternal(func, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while any combo for the function is held down, even when a screen is open.
|
||||
*/
|
||||
public static boolean isHeldAllowScreen(String func) {
|
||||
return isHeldInternal(func, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the given combo is held down, even when a screen is open.
|
||||
*/
|
||||
public static boolean isComboHeldAllowScreen(String comboString) {
|
||||
if (comboString == null || comboString.isEmpty() || comboString.equalsIgnoreCase("NONE")) return false;
|
||||
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
|
||||
if (combo.isEmpty()) return false;
|
||||
for (int key : combo) {
|
||||
if (!isKeyPressed(key, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Low-level input helpers
|
||||
// ============================================================
|
||||
|
||||
private static boolean isKeyPressed(int key, boolean allowScreen) {
|
||||
if (key == KeyUtil.NONE) return false;
|
||||
if (ClickGuiRenderer.waitingForKey || ClickGuiRenderer.isTextEditorActive()) return false;
|
||||
|
||||
Minecraft client = Minecraft.getInstance();
|
||||
if (!allowScreen && ClientScreen.current(client) != null) return false;
|
||||
|
||||
if (KeyUtil.isScancodeCode(key)) {
|
||||
return liveInputState.getOrDefault(key, false);
|
||||
}
|
||||
|
||||
long win = client.getWindow().handle();
|
||||
|
||||
if (KeyUtil.isMouseCode(key)) {
|
||||
int button = KeyUtil.codeToMouseButton(key);
|
||||
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return false;
|
||||
return GLFW.glfwGetMouseButton(win, button) == GLFW.GLFW_PRESS
|
||||
|| liveInputState.getOrDefault(key, false);
|
||||
}
|
||||
|
||||
return GLFW.glfwGetKey(win, key) == GLFW.GLFW_PRESS
|
||||
|| liveInputState.getOrDefault(key, false);
|
||||
}
|
||||
|
||||
private static boolean isHeldInternal(String func, boolean allowScreen) {
|
||||
if (ClickGuiSearch.isActive()) return false;
|
||||
if (!comboBinds.containsKey(func)) return false;
|
||||
|
||||
for (Set<Integer> combo : comboBinds.get(func)) {
|
||||
boolean allPressed = true;
|
||||
for (int key : combo) {
|
||||
if (!isKeyPressed(key, allowScreen)) {
|
||||
allPressed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPressed) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Debug / helpers
|
||||
// ============================================================
|
||||
|
||||
public static boolean isComboUsed(String comboString) {
|
||||
Set<Integer> combo = KeyUtil.stringToCombo(comboString);
|
||||
Set<String> funcs = reverseLookup.get(combo);
|
||||
return funcs != null && !funcs.isEmpty();
|
||||
}
|
||||
}
|
||||
372
mod/src/main/java/dev/loki/lovisual/util/input/KeyUtil.java
Normal file
372
mod/src/main/java/dev/loki/lovisual/util/input/KeyUtil.java
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
/*
|
||||
* 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.input;
|
||||
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public enum KeyUtil {
|
||||
;
|
||||
|
||||
public static final int NONE = GLFW.GLFW_KEY_UNKNOWN;
|
||||
|
||||
private static final int MOUSE_CODE_BASE = -4096;
|
||||
private static final int SCANCODE_CODE_BASE = -1_048_576;
|
||||
|
||||
private static final Map<String, Integer> NAME_TO_KEY = new HashMap<>();
|
||||
private static final Map<Integer, String> KEY_TO_NAME = new HashMap<>();
|
||||
|
||||
static {
|
||||
for (char c = 'A'; c <= 'Z'; c++) {
|
||||
put(String.valueOf(c), GLFW.GLFW_KEY_A + (c - 'A'));
|
||||
}
|
||||
|
||||
for (char c = '0'; c <= '9'; c++) {
|
||||
put(String.valueOf(c), GLFW.GLFW_KEY_0 + (c - '0'));
|
||||
}
|
||||
|
||||
for (int i = 1; i <= 25; i++) {
|
||||
put("F" + i, GLFW.GLFW_KEY_F1 + (i - 1));
|
||||
}
|
||||
|
||||
put("SPACE", GLFW.GLFW_KEY_SPACE);
|
||||
put("TAB", GLFW.GLFW_KEY_TAB);
|
||||
put("ENTER", GLFW.GLFW_KEY_ENTER);
|
||||
putAlias("RETURN", GLFW.GLFW_KEY_ENTER);
|
||||
put("ESCAPE", GLFW.GLFW_KEY_ESCAPE);
|
||||
putAlias("ESC", GLFW.GLFW_KEY_ESCAPE);
|
||||
put("BACKSPACE", GLFW.GLFW_KEY_BACKSPACE);
|
||||
put("DELETE", GLFW.GLFW_KEY_DELETE);
|
||||
put("INSERT", GLFW.GLFW_KEY_INSERT);
|
||||
put("HOME", GLFW.GLFW_KEY_HOME);
|
||||
put("END", GLFW.GLFW_KEY_END);
|
||||
put("PAGE_UP", GLFW.GLFW_KEY_PAGE_UP);
|
||||
putAlias("PGUP", GLFW.GLFW_KEY_PAGE_UP);
|
||||
put("PAGE_DOWN", GLFW.GLFW_KEY_PAGE_DOWN);
|
||||
putAlias("PGDN", GLFW.GLFW_KEY_PAGE_DOWN);
|
||||
|
||||
put("UP", GLFW.GLFW_KEY_UP);
|
||||
put("DOWN", GLFW.GLFW_KEY_DOWN);
|
||||
put("LEFT", GLFW.GLFW_KEY_LEFT);
|
||||
put("RIGHT", GLFW.GLFW_KEY_RIGHT);
|
||||
|
||||
put("LEFT_SHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
|
||||
put("RIGHT_SHIFT", GLFW.GLFW_KEY_RIGHT_SHIFT);
|
||||
putAlias("LSHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
|
||||
putAlias("RSHIFT", GLFW.GLFW_KEY_RIGHT_SHIFT);
|
||||
putAlias("SHIFT", GLFW.GLFW_KEY_LEFT_SHIFT);
|
||||
|
||||
put("LEFT_CTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
put("RIGHT_CTRL", GLFW.GLFW_KEY_RIGHT_CONTROL);
|
||||
putAlias("LEFT_CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
putAlias("RIGHT_CONTROL", GLFW.GLFW_KEY_RIGHT_CONTROL);
|
||||
putAlias("LCTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
putAlias("RCTRL", GLFW.GLFW_KEY_RIGHT_CONTROL);
|
||||
putAlias("CTRL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
putAlias("CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
|
||||
put("LEFT_ALT", GLFW.GLFW_KEY_LEFT_ALT);
|
||||
put("RIGHT_ALT", GLFW.GLFW_KEY_RIGHT_ALT);
|
||||
putAlias("LALT", GLFW.GLFW_KEY_LEFT_ALT);
|
||||
putAlias("RALT", GLFW.GLFW_KEY_RIGHT_ALT);
|
||||
putAlias("ALT", GLFW.GLFW_KEY_LEFT_ALT);
|
||||
|
||||
put("LEFT_SUPER", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
put("RIGHT_SUPER", GLFW.GLFW_KEY_RIGHT_SUPER);
|
||||
putAlias("LEFT_WIN", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
putAlias("RIGHT_WIN", GLFW.GLFW_KEY_RIGHT_SUPER);
|
||||
putAlias("LWIN", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
putAlias("RWIN", GLFW.GLFW_KEY_RIGHT_SUPER);
|
||||
putAlias("SUPER", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
putAlias("WIN", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
|
||||
put("CAPS_LOCK", GLFW.GLFW_KEY_CAPS_LOCK);
|
||||
put("SCROLL_LOCK", GLFW.GLFW_KEY_SCROLL_LOCK);
|
||||
put("NUM_LOCK", GLFW.GLFW_KEY_NUM_LOCK);
|
||||
put("PRINT_SCREEN", GLFW.GLFW_KEY_PRINT_SCREEN);
|
||||
put("PAUSE", GLFW.GLFW_KEY_PAUSE);
|
||||
put("MENU", GLFW.GLFW_KEY_MENU);
|
||||
|
||||
put("GRAVE_ACCENT", GLFW.GLFW_KEY_GRAVE_ACCENT);
|
||||
putAlias("GRAVE", GLFW.GLFW_KEY_GRAVE_ACCENT);
|
||||
putAlias("BACKTICK", GLFW.GLFW_KEY_GRAVE_ACCENT);
|
||||
put("APOSTROPHE", GLFW.GLFW_KEY_APOSTROPHE);
|
||||
put("BACKSLASH", GLFW.GLFW_KEY_BACKSLASH);
|
||||
put("COMMA", GLFW.GLFW_KEY_COMMA);
|
||||
put("EQUAL", GLFW.GLFW_KEY_EQUAL);
|
||||
put("LEFT_BRACKET", GLFW.GLFW_KEY_LEFT_BRACKET);
|
||||
put("RIGHT_BRACKET", GLFW.GLFW_KEY_RIGHT_BRACKET);
|
||||
put("MINUS", GLFW.GLFW_KEY_MINUS);
|
||||
put("PERIOD", GLFW.GLFW_KEY_PERIOD);
|
||||
put("SEMICOLON", GLFW.GLFW_KEY_SEMICOLON);
|
||||
put("SLASH", GLFW.GLFW_KEY_SLASH);
|
||||
put("WORLD_1", GLFW.GLFW_KEY_WORLD_1);
|
||||
put("WORLD_2", GLFW.GLFW_KEY_WORLD_2);
|
||||
|
||||
put("KP_0", GLFW.GLFW_KEY_KP_0);
|
||||
put("KP_1", GLFW.GLFW_KEY_KP_1);
|
||||
put("KP_2", GLFW.GLFW_KEY_KP_2);
|
||||
put("KP_3", GLFW.GLFW_KEY_KP_3);
|
||||
put("KP_4", GLFW.GLFW_KEY_KP_4);
|
||||
put("KP_5", GLFW.GLFW_KEY_KP_5);
|
||||
put("KP_6", GLFW.GLFW_KEY_KP_6);
|
||||
put("KP_7", GLFW.GLFW_KEY_KP_7);
|
||||
put("KP_8", GLFW.GLFW_KEY_KP_8);
|
||||
put("KP_9", GLFW.GLFW_KEY_KP_9);
|
||||
put("KP_DECIMAL", GLFW.GLFW_KEY_KP_DECIMAL);
|
||||
put("KP_DIVIDE", GLFW.GLFW_KEY_KP_DIVIDE);
|
||||
put("KP_MULTIPLY", GLFW.GLFW_KEY_KP_MULTIPLY);
|
||||
put("KP_SUBTRACT", GLFW.GLFW_KEY_KP_SUBTRACT);
|
||||
put("KP_ADD", GLFW.GLFW_KEY_KP_ADD);
|
||||
put("KP_ENTER", GLFW.GLFW_KEY_KP_ENTER);
|
||||
put("KP_EQUAL", GLFW.GLFW_KEY_KP_EQUAL);
|
||||
|
||||
putMouseAliases("MOUSE_LEFT", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAliases("MOUSE_RIGHT", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAliases("MOUSE_MIDDLE", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
putMouseAlias("MOUSE1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAlias("MOUSE_1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAlias("MOUSE_BUTTON_1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAlias("MOUSE2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAlias("MOUSE_2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAlias("MOUSE_BUTTON_2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAlias("MOUSE3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
putMouseAlias("MOUSE_3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
putMouseAlias("MOUSE_BUTTON_3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
putMouseAlias("MB1", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAlias("MB2", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAlias("MB3", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
for (int userButton = 4; userButton <= 8; userButton++) {
|
||||
int glfwButton = userButton - 1;
|
||||
putMouseAlias("MOUSE" + userButton, glfwButton);
|
||||
putMouseAlias("MOUSE_" + userButton, glfwButton);
|
||||
putMouseAlias("MOUSE_BUTTON_" + userButton, glfwButton);
|
||||
putMouseAlias("MB" + userButton, glfwButton);
|
||||
}
|
||||
|
||||
putTranslationAliases();
|
||||
}
|
||||
|
||||
private static void put(String name, int code) {
|
||||
String normalized = normalizeName(name);
|
||||
NAME_TO_KEY.put(normalized, code);
|
||||
KEY_TO_NAME.putIfAbsent(code, normalized);
|
||||
}
|
||||
|
||||
private static void putAlias(String name, int code) {
|
||||
NAME_TO_KEY.put(normalizeName(name), code);
|
||||
}
|
||||
|
||||
private static void putMouseAliases(String name, int button) {
|
||||
putMouseAlias(name, button);
|
||||
}
|
||||
|
||||
private static void putMouseAlias(String name, int button) {
|
||||
NAME_TO_KEY.put(normalizeName(name), mouseButtonToCode(button));
|
||||
}
|
||||
|
||||
private static void putTranslationAliases() {
|
||||
for (Map.Entry<Integer, String> entry : KEY_TO_NAME.entrySet()) {
|
||||
int code = entry.getKey();
|
||||
String name = entry.getValue().toLowerCase(Locale.ROOT).replace('_', '.');
|
||||
NAME_TO_KEY.put("KEY.KEYBOARD." + name.toUpperCase(Locale.ROOT), code);
|
||||
}
|
||||
putAlias("KEY.KEYBOARD.LEFT.CONTROL", GLFW.GLFW_KEY_LEFT_CONTROL);
|
||||
putAlias("KEY.KEYBOARD.RIGHT.CONTROL", GLFW.GLFW_KEY_RIGHT_CONTROL);
|
||||
putAlias("KEY.KEYBOARD.LEFT.WIN", GLFW.GLFW_KEY_LEFT_SUPER);
|
||||
putAlias("KEY.KEYBOARD.RIGHT.WIN", GLFW.GLFW_KEY_RIGHT_SUPER);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.0", GLFW.GLFW_KEY_KP_0);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.1", GLFW.GLFW_KEY_KP_1);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.2", GLFW.GLFW_KEY_KP_2);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.3", GLFW.GLFW_KEY_KP_3);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.4", GLFW.GLFW_KEY_KP_4);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.5", GLFW.GLFW_KEY_KP_5);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.6", GLFW.GLFW_KEY_KP_6);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.7", GLFW.GLFW_KEY_KP_7);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.8", GLFW.GLFW_KEY_KP_8);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.9", GLFW.GLFW_KEY_KP_9);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.ADD", GLFW.GLFW_KEY_KP_ADD);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.DECIMAL", GLFW.GLFW_KEY_KP_DECIMAL);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.DIVIDE", GLFW.GLFW_KEY_KP_DIVIDE);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.ENTER", GLFW.GLFW_KEY_KP_ENTER);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.EQUAL", GLFW.GLFW_KEY_KP_EQUAL);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.MULTIPLY", GLFW.GLFW_KEY_KP_MULTIPLY);
|
||||
putAlias("KEY.KEYBOARD.KEYPAD.SUBTRACT", GLFW.GLFW_KEY_KP_SUBTRACT);
|
||||
|
||||
putMouseAlias("KEY.MOUSE.LEFT", GLFW.GLFW_MOUSE_BUTTON_LEFT);
|
||||
putMouseAlias("KEY.MOUSE.RIGHT", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
|
||||
putMouseAlias("KEY.MOUSE.MIDDLE", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
|
||||
for (int userButton = 4; userButton <= 8; userButton++) {
|
||||
putMouseAlias("KEY.MOUSE." + userButton, userButton - 1);
|
||||
}
|
||||
}
|
||||
|
||||
public static int mouseButtonToCode(int button) {
|
||||
return MOUSE_CODE_BASE - Math.max(0, button);
|
||||
}
|
||||
|
||||
public static boolean isMouseCode(int code) {
|
||||
return code <= MOUSE_CODE_BASE && code > SCANCODE_CODE_BASE;
|
||||
}
|
||||
|
||||
public static int codeToMouseButton(int code) {
|
||||
return MOUSE_CODE_BASE - code;
|
||||
}
|
||||
|
||||
public static int scancodeToCode(int scancode) {
|
||||
return SCANCODE_CODE_BASE - Math.max(0, scancode);
|
||||
}
|
||||
|
||||
public static boolean isScancodeCode(int code) {
|
||||
return code <= SCANCODE_CODE_BASE;
|
||||
}
|
||||
|
||||
public static int codeToScancode(int code) {
|
||||
return SCANCODE_CODE_BASE - code;
|
||||
}
|
||||
|
||||
public static int fromKeyInput(int key, int scancode) {
|
||||
if (key != GLFW.GLFW_KEY_UNKNOWN) return key;
|
||||
return scancode >= 0 ? scancodeToCode(scancode) : NONE;
|
||||
}
|
||||
|
||||
public static int nameToKey(String name) {
|
||||
if (name == null) return NONE;
|
||||
|
||||
String normalized = normalizeName(name);
|
||||
if (normalized.isEmpty() || normalized.equals("NONE")) return NONE;
|
||||
|
||||
Integer mapped = NAME_TO_KEY.get(normalized);
|
||||
if (mapped != null) return mapped;
|
||||
|
||||
if (normalized.startsWith("KEY_") && normalized.length() > 4) {
|
||||
Integer parsed = parseInteger(normalized.substring(4));
|
||||
return parsed != null ? parsed : NONE;
|
||||
}
|
||||
|
||||
if (normalized.startsWith("SCANCODE_") && normalized.length() > 9) {
|
||||
Integer parsed = parseInteger(normalized.substring(9));
|
||||
return parsed != null ? scancodeToCode(parsed) : NONE;
|
||||
}
|
||||
|
||||
Integer mouse = parseMouseName(normalized);
|
||||
if (mouse != null) return mouseButtonToCode(mouse);
|
||||
|
||||
return NONE;
|
||||
}
|
||||
|
||||
public static String keyToName(int key) {
|
||||
if (key == NONE) return "NONE";
|
||||
|
||||
if (isMouseCode(key)) {
|
||||
int button = codeToMouseButton(key);
|
||||
return switch (button) {
|
||||
case GLFW.GLFW_MOUSE_BUTTON_LEFT -> "MOUSE_LEFT";
|
||||
case GLFW.GLFW_MOUSE_BUTTON_RIGHT -> "MOUSE_RIGHT";
|
||||
case GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> "MOUSE_MIDDLE";
|
||||
default -> "MOUSE_" + (button + 1);
|
||||
};
|
||||
}
|
||||
|
||||
if (isScancodeCode(key)) {
|
||||
return "SCANCODE_" + codeToScancode(key);
|
||||
}
|
||||
|
||||
String known = KEY_TO_NAME.get(key);
|
||||
if (known != null) return known;
|
||||
|
||||
return "KEY_" + key;
|
||||
}
|
||||
|
||||
public static Set<Integer> stringToCombo(String str) {
|
||||
Set<Integer> set = new HashSet<>();
|
||||
if (str == null || str.isBlank()) return set;
|
||||
|
||||
for (String part : str.split("\\+")) {
|
||||
int code = nameToKey(part.trim());
|
||||
if (code != NONE) set.add(code);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
public static String comboToString(Set<Integer> keys) {
|
||||
if (keys == null || keys.isEmpty()) return "NONE";
|
||||
|
||||
List<Integer> sorted = new ArrayList<>(keys);
|
||||
sorted.sort(KeyUtil::compareKeysForDisplay);
|
||||
|
||||
List<String> names = new ArrayList<>(sorted.size());
|
||||
for (int key : sorted) {
|
||||
names.add(keyToName(key));
|
||||
}
|
||||
|
||||
return String.join("+", names);
|
||||
}
|
||||
|
||||
public static boolean isModifierCode(int code) {
|
||||
return code == GLFW.GLFW_KEY_LEFT_SHIFT
|
||||
|| code == GLFW.GLFW_KEY_RIGHT_SHIFT
|
||||
|| code == GLFW.GLFW_KEY_LEFT_CONTROL
|
||||
|| code == GLFW.GLFW_KEY_RIGHT_CONTROL
|
||||
|| code == GLFW.GLFW_KEY_LEFT_ALT
|
||||
|| code == GLFW.GLFW_KEY_RIGHT_ALT
|
||||
|| code == GLFW.GLFW_KEY_LEFT_SUPER
|
||||
|| code == GLFW.GLFW_KEY_RIGHT_SUPER;
|
||||
}
|
||||
|
||||
private static int compareKeysForDisplay(int a, int b) {
|
||||
int ar = sortRank(a);
|
||||
int br = sortRank(b);
|
||||
if (ar != br) return Integer.compare(ar, br);
|
||||
return keyToName(a).compareTo(keyToName(b));
|
||||
}
|
||||
|
||||
private static int sortRank(int code) {
|
||||
if (isModifierCode(code)) return 0;
|
||||
if (isMouseCode(code)) return 2;
|
||||
if (isScancodeCode(code)) return 3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static Integer parseMouseName(String normalized) {
|
||||
String s = normalized;
|
||||
if (s.startsWith("MOUSE_BUTTON_")) s = s.substring("MOUSE_BUTTON_".length());
|
||||
else if (s.startsWith("MOUSE_")) s = s.substring("MOUSE_".length());
|
||||
else if (s.startsWith("MOUSE")) s = s.substring("MOUSE".length());
|
||||
else if (s.startsWith("MB")) s = s.substring(2);
|
||||
else return null;
|
||||
|
||||
return switch (s) {
|
||||
case "LEFT" -> GLFW.GLFW_MOUSE_BUTTON_LEFT;
|
||||
case "RIGHT" -> GLFW.GLFW_MOUSE_BUTTON_RIGHT;
|
||||
case "MIDDLE" -> GLFW.GLFW_MOUSE_BUTTON_MIDDLE;
|
||||
default -> {
|
||||
Integer parsed = parseInteger(s);
|
||||
if (parsed == null || parsed < 1 || parsed > 8) yield null;
|
||||
yield parsed - 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Integer parseInteger(String s) {
|
||||
try {
|
||||
return Integer.parseInt(s);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeName(String name) {
|
||||
return name.trim()
|
||||
.toUpperCase(Locale.ROOT)
|
||||
.replace('-', '_')
|
||||
.replace(' ', '_');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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.item.enchant;
|
||||
|
||||
import net.minecraft.client.resources.language.I18n;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public record EnchantMeta(
|
||||
String key, // path: "lunge", "sharpness"
|
||||
String category // "armor", "melee", "other"
|
||||
) {
|
||||
public String translationKey() {
|
||||
return "enchantment.minecraft." + key;
|
||||
}
|
||||
|
||||
public String localizedName() {
|
||||
String translationKey = translationKey();
|
||||
String translated = I18n.get(translationKey);
|
||||
if (translated != null && !translated.isBlank() && !translationKey.equals(translated)) {
|
||||
return translated.trim();
|
||||
}
|
||||
return fallbackName();
|
||||
}
|
||||
|
||||
public String shortName() {
|
||||
return abbreviate(localizedName());
|
||||
}
|
||||
|
||||
static String abbreviate(String name) {
|
||||
if (name == null || name.isBlank()) return "";
|
||||
|
||||
String[] rawWords = name.trim().split("[^\\p{L}\\p{N}]+");
|
||||
List<String> words = new ArrayList<>(rawWords.length);
|
||||
for (String word : rawWords) {
|
||||
if (!word.isBlank()) words.add(word);
|
||||
}
|
||||
if (words.isEmpty()) return "";
|
||||
if (words.size() == 1) return codePointPrefix(words.getFirst(), 3);
|
||||
|
||||
// Connector words do not carry meaning in a compact label:
|
||||
// "Bane of Arthropods" -> "BA", "Защита от снарядов" -> "ЗС".
|
||||
List<String> meaningful = words.stream()
|
||||
.filter(word -> word.codePointCount(0, word.length()) > 2)
|
||||
.toList();
|
||||
if (meaningful.isEmpty()) meaningful = words;
|
||||
|
||||
StringBuilder result = new StringBuilder(Math.min(meaningful.size(), 3));
|
||||
for (String word : meaningful) {
|
||||
if (result.codePointCount(0, result.length()) >= 3) break;
|
||||
result.appendCodePoint(Character.toUpperCase(word.codePointAt(0)));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private String fallbackName() {
|
||||
String[] words = key.toLowerCase(Locale.ROOT).split("_");
|
||||
StringBuilder result = new StringBuilder(key.length() + 4);
|
||||
for (String word : words) {
|
||||
if (word.isBlank()) continue;
|
||||
if (!result.isEmpty()) result.append(' ');
|
||||
int first = word.codePointAt(0);
|
||||
result.appendCodePoint(Character.toUpperCase(first));
|
||||
result.append(word.substring(Character.charCount(first)));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String codePointPrefix(String value, int length) {
|
||||
int count = Math.min(length, value.codePointCount(0, value.length()));
|
||||
return value.substring(0, value.offsetByCodePoints(0, count));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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.item.enchant;
|
||||
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.Enchantments;
|
||||
|
||||
import java.util.Map;
|
||||
import dev.loki.lovisual.util.item.enchant.EnchantMeta;
|
||||
|
||||
public enum EnchantRegistry {
|
||||
;
|
||||
|
||||
public static final Map<ResourceKey<Enchantment>, EnchantMeta> REGISTRY = Map.ofEntries(
|
||||
|
||||
/* ================= ARMOR ================= */
|
||||
Map.entry(Enchantments.PROTECTION, new EnchantMeta("protection", "armor")),
|
||||
Map.entry(Enchantments.FIRE_PROTECTION, new EnchantMeta("fire_protection", "armor")),
|
||||
Map.entry(Enchantments.FEATHER_FALLING, new EnchantMeta("feather_falling", "armor")),
|
||||
Map.entry(Enchantments.BLAST_PROTECTION, new EnchantMeta("blast_protection", "armor")),
|
||||
Map.entry(Enchantments.PROJECTILE_PROTECTION, new EnchantMeta("projectile_protection", "armor")),
|
||||
Map.entry(Enchantments.RESPIRATION, new EnchantMeta("respiration", "armor")),
|
||||
Map.entry(Enchantments.AQUA_AFFINITY, new EnchantMeta("aqua_affinity", "armor")),
|
||||
Map.entry(Enchantments.THORNS, new EnchantMeta("thorns", "armor")),
|
||||
Map.entry(Enchantments.DEPTH_STRIDER, new EnchantMeta("depth_strider", "armor")),
|
||||
Map.entry(Enchantments.FROST_WALKER, new EnchantMeta("frost_walker", "armor")),
|
||||
Map.entry(Enchantments.SOUL_SPEED, new EnchantMeta("soul_speed", "armor")),
|
||||
Map.entry(Enchantments.SWIFT_SNEAK, new EnchantMeta("swift_sneak", "armor")),
|
||||
Map.entry(Enchantments.BINDING_CURSE, new EnchantMeta("binding_curse", "armor")),
|
||||
|
||||
/* ================= MELEE ================= */
|
||||
Map.entry(Enchantments.SHARPNESS, new EnchantMeta("sharpness", "melee")),
|
||||
Map.entry(Enchantments.SMITE, new EnchantMeta("smite", "melee")),
|
||||
Map.entry(Enchantments.BANE_OF_ARTHROPODS, new EnchantMeta("bane_of_arthropods", "melee")),
|
||||
Map.entry(Enchantments.KNOCKBACK, new EnchantMeta("knockback", "melee")),
|
||||
Map.entry(Enchantments.FIRE_ASPECT, new EnchantMeta("fire_aspect", "melee")),
|
||||
Map.entry(Enchantments.LOOTING, new EnchantMeta("looting", "melee")),
|
||||
Map.entry(Enchantments.SWEEPING_EDGE, new EnchantMeta("sweeping_edge", "melee")),
|
||||
|
||||
/* ================= TOOLS ================= */
|
||||
Map.entry(Enchantments.EFFICIENCY, new EnchantMeta("efficiency", "tools")),
|
||||
Map.entry(Enchantments.SILK_TOUCH, new EnchantMeta("silk_touch", "tools")),
|
||||
Map.entry(Enchantments.FORTUNE, new EnchantMeta("fortune", "tools")),
|
||||
|
||||
/* ================= BOW ================= */
|
||||
Map.entry(Enchantments.POWER, new EnchantMeta("power", "bow")),
|
||||
Map.entry(Enchantments.PUNCH, new EnchantMeta("punch", "bow")),
|
||||
Map.entry(Enchantments.FLAME, new EnchantMeta("flame", "bow")),
|
||||
Map.entry(Enchantments.INFINITY, new EnchantMeta("infinity", "bow")),
|
||||
|
||||
/* ================= FISHING ================= */
|
||||
Map.entry(Enchantments.LUCK_OF_THE_SEA, new EnchantMeta("luck_of_the_sea", "fishing")),
|
||||
Map.entry(Enchantments.LURE, new EnchantMeta("lure", "fishing")),
|
||||
|
||||
/* ================= TRIDENT ================= */
|
||||
Map.entry(Enchantments.LOYALTY, new EnchantMeta("loyalty", "trident")),
|
||||
Map.entry(Enchantments.IMPALING, new EnchantMeta("impaling", "trident")),
|
||||
Map.entry(Enchantments.RIPTIDE, new EnchantMeta("riptide", "trident")),
|
||||
Map.entry(Enchantments.CHANNELING, new EnchantMeta("channeling", "trident")),
|
||||
|
||||
/* ================= CROSSBOW ================= */
|
||||
Map.entry(Enchantments.MULTISHOT, new EnchantMeta("multishot", "crossbow")),
|
||||
Map.entry(Enchantments.QUICK_CHARGE, new EnchantMeta("quick_charge", "crossbow")),
|
||||
Map.entry(Enchantments.PIERCING, new EnchantMeta("piercing", "crossbow")),
|
||||
|
||||
/* ================= MACE ================= */
|
||||
Map.entry(Enchantments.DENSITY, new EnchantMeta("density", "mace")),
|
||||
Map.entry(Enchantments.BREACH, new EnchantMeta("breach", "mace")),
|
||||
Map.entry(Enchantments.WIND_BURST, new EnchantMeta("wind_burst", "mace")),
|
||||
|
||||
/* ================= OTHER ================= */
|
||||
Map.entry(Enchantments.LUNGE, new EnchantMeta("lunge", "other")),
|
||||
Map.entry(Enchantments.MENDING, new EnchantMeta("mending", "other")),
|
||||
Map.entry(Enchantments.UNBREAKING, new EnchantMeta("unbreaking", "other")),
|
||||
Map.entry(Enchantments.VANISHING_CURSE, new EnchantMeta("vanishing_curse", "other"))
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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.item.enchant;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
|
||||
/**
|
||||
* Shared helpers for enchantment validation/formatting.
|
||||
*/
|
||||
public enum EnchantUtil {
|
||||
;
|
||||
|
||||
/**
|
||||
* Checks if the provided enchantment level exceeds its max level.
|
||||
*/
|
||||
public static boolean isInvalidLevel(Holder<Enchantment> enchant, int level) {
|
||||
if (enchant == null || level <= 0) return false;
|
||||
Enchantment value = enchant.value();
|
||||
int max = value.getMaxLevel();
|
||||
return level > max;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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.item.enchant;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.EnchantmentHelper;
|
||||
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
||||
import dev.loki.lovisual.config.values.collection.ItemIdSetValue;
|
||||
import dev.loki.lovisual.config.values.color.RGBColorValue;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.nametags.NameTags;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.impl.BetterChat;
|
||||
import dev.loki.lovisual.util.item.food.IllegalItemUtil;
|
||||
|
||||
/**
|
||||
* Shared helper for highlighting top (max-level) legal enchants used by DropESP/NameTags/BetterChat.
|
||||
* Illegal enchantments always take priority (see IllegalItemUtil).
|
||||
*/
|
||||
public enum TopEnchantUtil {
|
||||
;
|
||||
|
||||
private static final RGBColorValue TOP_COLOR = new RGBColorValue("top_enchant_color", "#B84DFF");
|
||||
private static final ItemIdSetValue IGNORE_TOP = new ItemIdSetValue("top_enchant_ignore", buildDefaultIgnore());
|
||||
|
||||
public static RGBColorValue topColorValue() {
|
||||
return TOP_COLOR;
|
||||
}
|
||||
|
||||
public static ItemIdSetValue ignoreValue() {
|
||||
return IGNORE_TOP;
|
||||
}
|
||||
|
||||
public static int topColor() {
|
||||
return TOP_COLOR.getArgb();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the stack has at least one enchant at its vanilla max level,
|
||||
* while not being illegal and not in the ignore list.
|
||||
*/
|
||||
public static boolean hasTopEnchant(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) return false;
|
||||
if (IllegalItemUtil.isIllegal(stack)) return false; // illegal has higher priority
|
||||
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
|
||||
Set<String> ignore = IGNORE_TOP.get();
|
||||
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
|
||||
Holder<Enchantment> ref = entry.getKey();
|
||||
String id = safeId(ref);
|
||||
if (ignore.contains(id)) continue;
|
||||
int level = entry.getIntValue();
|
||||
if (level <= 0) continue;
|
||||
Enchantment ench = ref.value();
|
||||
if (level == ench.getMaxLevel()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects (label, isTop) pairs, skipping ignore-list entries.
|
||||
*/
|
||||
public static void collectTopEnchants(ItemStack stack, BiConsumer<String, Boolean> consumer) {
|
||||
if (stack == null || stack.isEmpty() || consumer == null) return;
|
||||
Set<String> ignore = IGNORE_TOP.get();
|
||||
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
|
||||
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
|
||||
Holder<Enchantment> ref = entry.getKey();
|
||||
String id = safeId(ref);
|
||||
if (ignore.contains(id)) continue;
|
||||
int level = entry.getIntValue();
|
||||
if (level <= 0) continue;
|
||||
Enchantment ench = ref.value();
|
||||
boolean isTop = level == ench.getMaxLevel();
|
||||
String label = Enchantment.getFullname(ref, level).getString();
|
||||
consumer.accept(label, isTop);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeId(Holder<Enchantment> ref) {
|
||||
return ref.unwrapKey().map(ResourceKey::identifier).map(Object::toString).orElse("");
|
||||
}
|
||||
|
||||
private static Set<String> buildDefaultIgnore() {
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
// Known single-level enchants
|
||||
result.add("minecraft:binding_curse");
|
||||
result.add("minecraft:vanishing_curse");
|
||||
result.add("minecraft:mending");
|
||||
result.add("minecraft:infinity");
|
||||
result.add("minecraft:silk_touch");
|
||||
result.add("minecraft:aqua_affinity");
|
||||
result.add("minecraft:flame");
|
||||
result.add("minecraft:channeling");
|
||||
result.add("minecraft:multishot");
|
||||
// Explicitly exclude Unbreaking as top (lvl 3)
|
||||
result.add("minecraft:unbreaking");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
202
mod/src/main/java/dev/loki/lovisual/util/item/food/FoodUtil.java
Normal file
202
mod/src/main/java/dev/loki/lovisual/util/item/food/FoodUtil.java
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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.item.food;
|
||||
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectCategory;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.food.FoodData;
|
||||
import net.minecraft.world.food.FoodProperties;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.component.Consumable;
|
||||
import net.minecraft.world.item.consume_effects.ApplyStatusEffectsConsumeEffect;
|
||||
import net.minecraft.world.item.consume_effects.ConsumeEffect;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public enum FoodUtil {
|
||||
;
|
||||
|
||||
public static boolean isFood(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) return false;
|
||||
return stack.get(DataComponents.FOOD) != null;
|
||||
}
|
||||
|
||||
public static int getFoodLevel(Player player) {
|
||||
if (player == null) return 0;
|
||||
FoodData hungerManager = player.getFoodData();
|
||||
if (hungerManager == null) return 0;
|
||||
return hungerManager.getFoodLevel();
|
||||
}
|
||||
|
||||
public static float getSaturationLevel(Player player) {
|
||||
if (player == null) return 0f;
|
||||
FoodData hungerManager = player.getFoodData();
|
||||
if (hungerManager == null) return 0f;
|
||||
return hungerManager.getSaturationLevel();
|
||||
}
|
||||
|
||||
public static int getNutrition(ItemStack stack) {
|
||||
Object food = getFoodComponent(stack);
|
||||
if (food instanceof FoodProperties fc) {
|
||||
return fc.nutrition();
|
||||
}
|
||||
return food == null ? 0 : readInt(food, "nutrition", "getNutrition");
|
||||
}
|
||||
|
||||
public static float getSaturation(ItemStack stack) {
|
||||
Object food = getFoodComponent(stack);
|
||||
if (food instanceof FoodProperties fc) {
|
||||
return fc.saturation();
|
||||
}
|
||||
return food == null ? 0f : readFloat(food, "saturation", "getSaturation", "getSaturationModifier", "saturationModifier");
|
||||
}
|
||||
|
||||
public static boolean canAlwaysEat(ItemStack stack) {
|
||||
Object food = getFoodComponent(stack);
|
||||
if (food instanceof FoodProperties fc) {
|
||||
return readBoolean(fc, "canAlwaysEat", "isAlwaysEdible");
|
||||
}
|
||||
return food != null && readBoolean(food, "canAlwaysEat", "isAlwaysEdible");
|
||||
}
|
||||
|
||||
public static float scoreFood(ItemStack stack) {
|
||||
if (!isFood(stack)) return Float.NEGATIVE_INFINITY;
|
||||
int nutrition = getNutrition(stack);
|
||||
float saturation = getSaturation(stack);
|
||||
float score = nutrition * 10.0f + saturation * 20.0f;
|
||||
score += scoreEffects(stack);
|
||||
return score;
|
||||
}
|
||||
|
||||
public static List<MobEffectInstance> getFoodEffects(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) return Collections.emptyList();
|
||||
List<MobEffectInstance> out = new java.util.ArrayList<>();
|
||||
Consumable consumable = stack.get(DataComponents.CONSUMABLE);
|
||||
if (consumable == null) return Collections.emptyList();
|
||||
for (ConsumeEffect effect : consumable.onConsumeEffects()) {
|
||||
if (effect instanceof ApplyStatusEffectsConsumeEffect apply) {
|
||||
for (MobEffectInstance inst : apply.effects()) {
|
||||
out.add(new MobEffectInstance(inst));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static float scoreEffects(ItemStack stack) {
|
||||
Object food = getFoodComponent(stack);
|
||||
if (food == null) return 0f;
|
||||
List<?> effects = readList(food, "effects", "getEffects", "getStatusEffects");
|
||||
if (effects.isEmpty()) return 0f;
|
||||
|
||||
float score = 0f;
|
||||
for (Object entry : effects) {
|
||||
if (entry == null) continue;
|
||||
float chance = readFloat(entry, "probability", "getProbability", "getChance");
|
||||
if (chance <= 0f) chance = 1f;
|
||||
|
||||
MobEffect effect = extractStatusEffect(entry);
|
||||
if (effect == null) {
|
||||
score += 2.0f * chance;
|
||||
continue;
|
||||
}
|
||||
|
||||
MobEffectCategory cat = effect.getCategory();
|
||||
float add = switch (cat) {
|
||||
case BENEFICIAL -> 6.0f;
|
||||
case HARMFUL -> -6.0f;
|
||||
default -> 2.0f;
|
||||
};
|
||||
score += add * chance;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
private static MobEffect extractStatusEffect(Object effectEntry) {
|
||||
Object obj = readObject(effectEntry, "effect", "getEffect", "getFirst", "getLeft");
|
||||
if (obj instanceof MobEffectInstance inst) {
|
||||
Object type = inst.getEffect();
|
||||
if (type instanceof MobEffect effect) return effect;
|
||||
if (type instanceof net.minecraft.core.Holder<?> regEntry) {
|
||||
Object v = regEntry.value();
|
||||
if (v instanceof MobEffect effect) return effect;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj instanceof MobEffect effect) {
|
||||
return effect;
|
||||
}
|
||||
|
||||
if (obj instanceof net.minecraft.core.Holder<?> regEntry) {
|
||||
Object v = regEntry.value();
|
||||
if (v instanceof MobEffect effect) {
|
||||
return effect;
|
||||
}
|
||||
}
|
||||
|
||||
Object fromInstance = readObject(obj, "getEffectType", "getEffect");
|
||||
if (fromInstance instanceof MobEffect effect) {
|
||||
return effect;
|
||||
}
|
||||
|
||||
Object value = readObject(obj, "value");
|
||||
if (value instanceof MobEffect effect) {
|
||||
return effect;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object getFoodComponent(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) return null;
|
||||
return stack.get(DataComponents.FOOD);
|
||||
}
|
||||
|
||||
private static int readInt(Object target, String... methods) {
|
||||
Number n = readNumber(target, methods);
|
||||
return n == null ? 0 : n.intValue();
|
||||
}
|
||||
|
||||
private static float readFloat(Object target, String... methods) {
|
||||
Number n = readNumber(target, methods);
|
||||
return n == null ? 0f : n.floatValue();
|
||||
}
|
||||
|
||||
private static boolean readBoolean(Object target, String... methods) {
|
||||
Object v = readObject(target, methods);
|
||||
return v instanceof Boolean b && b;
|
||||
}
|
||||
|
||||
private static Number readNumber(Object target, String... methods) {
|
||||
Object v = readObject(target, methods);
|
||||
return v instanceof Number ? (Number) v : null;
|
||||
}
|
||||
|
||||
private static List<?> readList(Object target, String... methods) {
|
||||
Object v = readObject(target, methods);
|
||||
if (v instanceof List<?> list) return list;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private static Object readObject(Object target, String... methods) {
|
||||
if (target == null) return null;
|
||||
for (String name : methods) {
|
||||
try {
|
||||
Method m = target.getClass().getMethod(name);
|
||||
return m.invoke(target);
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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.item.food;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.EnchantmentHelper;
|
||||
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
||||
import dev.loki.lovisual.config.values.color.RGBColorValue;
|
||||
|
||||
import java.util.function.BiConsumer;
|
||||
import dev.loki.lovisual.util.item.enchant.EnchantUtil;
|
||||
|
||||
/**
|
||||
* Общая утилита проверки нелегальных предметов и цвета их подсветки.
|
||||
*/
|
||||
public enum IllegalItemUtil {
|
||||
;
|
||||
|
||||
private static final RGBColorValue ILLEGAL_COLOR = new RGBColorValue("illegal_item_color", "#FF5555");
|
||||
|
||||
public static RGBColorValue illegalColorValue() {
|
||||
return ILLEGAL_COLOR;
|
||||
}
|
||||
|
||||
public static int illegalColor() {
|
||||
return ILLEGAL_COLOR.getArgb();
|
||||
}
|
||||
|
||||
/**
|
||||
* true, если стек содержит энчанты выше разрешенного уровня.
|
||||
*/
|
||||
public static boolean isIllegal(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) return false;
|
||||
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
|
||||
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
|
||||
Holder<Enchantment> ref = entry.getKey();
|
||||
int level = entry.getIntValue();
|
||||
if (level <= 0) continue;
|
||||
if (EnchantUtil.isInvalidLevel(ref, level)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обход энчантов с признаком нелегальности.
|
||||
*/
|
||||
public static void collectEnchants(ItemStack stack, BiConsumer<String, Boolean> consumer) {
|
||||
if (stack == null || stack.isEmpty() || consumer == null) return;
|
||||
ItemEnchantments enchComp = EnchantmentHelper.getEnchantmentsForCrafting(stack);
|
||||
for (Object2IntMap.Entry<Holder<Enchantment>> entry : enchComp.entrySet()) {
|
||||
Holder<Enchantment> ref = entry.getKey();
|
||||
int level = entry.getIntValue();
|
||||
if (level <= 0) continue;
|
||||
boolean overMax = EnchantUtil.isInvalidLevel(ref, level);
|
||||
String label = Enchantment.getFullname(ref, level).getString();
|
||||
consumer.accept(label, overMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.item.rarity;
|
||||
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/**
|
||||
* Интерфейс для классов, которые могут извлекать цвет предмета
|
||||
* на основе его редкости или других свойств.
|
||||
*/
|
||||
public interface ItemRarityColorProvider {
|
||||
|
||||
/**
|
||||
* Возвращает RGBA цвет (0..1) для данного предмета.
|
||||
*
|
||||
* @param stack предмет
|
||||
* @return массив float[4] {r,g,b,a}
|
||||
*/
|
||||
float[] getRarityColor(ItemStack stack);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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.item.rarity;
|
||||
|
||||
import net.minecraft.world.item.Rarity;
|
||||
import dev.loki.lovisual.config.definition.ConfigNameProvider;
|
||||
import dev.loki.lovisual.config.definition.ConfigObject;
|
||||
import dev.loki.lovisual.config.io.ConfigSerializer;
|
||||
import dev.loki.lovisual.config.values.collection.ConfigValue;
|
||||
import dev.loki.lovisual.config.values.color.RGBColorValue;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Editable rarity colors (RGB) for tooltips/DropESP/etc.
|
||||
*/
|
||||
public final class RarityColorConfig implements ConfigObject, ConfigNameProvider {
|
||||
|
||||
public static final RarityColorConfig INSTANCE = new RarityColorConfig();
|
||||
private final RGBColorValue common = new RGBColorValue("rarity_common", "#FFFFFF");
|
||||
private final RGBColorValue uncommon = new RGBColorValue("rarity_uncommon", "#55FF55");
|
||||
private final RGBColorValue rare = new RGBColorValue("rarity_rare", "#5555FF");
|
||||
private final RGBColorValue epic = new RGBColorValue("rarity_epic", "#FF55FF");
|
||||
|
||||
private RarityColorConfig() {
|
||||
ConfigSerializer.load(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getConfigName() {
|
||||
return "raritycolorconfig";
|
||||
}
|
||||
|
||||
public int getColor(Rarity rarity) {
|
||||
if (rarity == null) return common.getArgb();
|
||||
return switch (rarity) {
|
||||
case UNCOMMON -> uncommon.getArgb();
|
||||
case RARE -> rare.getArgb();
|
||||
case EPIC -> epic.getArgb();
|
||||
default -> common.getArgb();
|
||||
};
|
||||
}
|
||||
|
||||
public RGBColorValue commonValue() {
|
||||
return common;
|
||||
}
|
||||
|
||||
public RGBColorValue uncommonValue() {
|
||||
return uncommon;
|
||||
}
|
||||
|
||||
public RGBColorValue rareValue() {
|
||||
return rare;
|
||||
}
|
||||
|
||||
public RGBColorValue epicValue() {
|
||||
return epic;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ConfigValue<?>> getConfigValues() {
|
||||
List<ConfigValue<?>> list = new ArrayList<>();
|
||||
list.add(common);
|
||||
list.add(uncommon);
|
||||
list.add(rare);
|
||||
list.add(epic);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.item.rarity;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.esp.overlay.DropESP;
|
||||
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Rarity;
|
||||
import dev.loki.lovisual.util.item.rarity.ItemRarityColorProvider;
|
||||
import dev.loki.lovisual.util.item.rarity.RarityColorConfig;
|
||||
|
||||
/**
|
||||
* Rarity -> RGBA for DropESP. Зачарованные вещи имеют минимум RARE.
|
||||
*/
|
||||
public final class RarityColorUtil implements ItemRarityColorProvider {
|
||||
|
||||
public static final RarityColorUtil INSTANCE = new RarityColorUtil();
|
||||
|
||||
private RarityColorUtil() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public float[] getRarityColor(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return white();
|
||||
}
|
||||
|
||||
Rarity rarity = stack.get(DataComponents.RARITY);
|
||||
if (rarity == null) rarity = Rarity.COMMON;
|
||||
|
||||
if (stack.isEnchanted()) {
|
||||
rarity = forceAtLeastRarity(rarity, Rarity.RARE);
|
||||
}
|
||||
|
||||
int argb = RarityColorConfig.INSTANCE.getColor(rarity);
|
||||
int rgb = argb & 0x00FFFFFF;
|
||||
float r = (rgb >> 16 & 0xFF) / 255f;
|
||||
float g = (rgb >> 8 & 0xFF) / 255f;
|
||||
float b = (rgb & 0xFF) / 255f;
|
||||
|
||||
return new float[]{r, g, b, 1f};
|
||||
}
|
||||
|
||||
private Rarity forceAtLeastRarity(Rarity current, Rarity min) {
|
||||
return current.ordinal() < min.ordinal() ? min : current;
|
||||
}
|
||||
|
||||
private float[] white() {
|
||||
return new float[]{1f, 1f, 1f, 1f};
|
||||
}
|
||||
}
|
||||
207
mod/src/main/java/dev/loki/lovisual/util/logging/DebugLog.java
Normal file
207
mod/src/main/java/dev/loki/lovisual/util/logging/DebugLog.java
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* 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.logging;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
public enum DebugLog {
|
||||
;
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("LoVisual");
|
||||
private static final Set<String> ONCE_KEYS = ConcurrentHashMap.newKeySet();
|
||||
private static final ConcurrentMap<String, String> LAST_STATES = new ConcurrentHashMap<>();
|
||||
private static volatile DebugMode mode = DebugMode.OFF;
|
||||
|
||||
public static void setMode(DebugMode mode) {
|
||||
DebugLog.mode = mode == null ? DebugMode.OFF : mode;
|
||||
}
|
||||
|
||||
public static DebugMode mode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public static boolean isEnabled() {
|
||||
return mode != DebugMode.OFF;
|
||||
}
|
||||
|
||||
public static boolean serverOnly() {
|
||||
return mode == DebugMode.SERVERDEBUG;
|
||||
}
|
||||
|
||||
public static boolean isStencilDebugEnabled() {
|
||||
return branchEnabled(Branch.STENCIL);
|
||||
}
|
||||
|
||||
public static boolean isRenderThreadDebugEnabled() {
|
||||
return branchEnabled(Branch.RENDER_THREAD);
|
||||
}
|
||||
|
||||
public static boolean isConfigDebugEnabled() {
|
||||
return branchEnabled(Branch.CONFIG);
|
||||
}
|
||||
|
||||
public static void info(String message, Object... args) {
|
||||
logInfo(Branch.GENERAL, "", message, args);
|
||||
}
|
||||
|
||||
public static void config(String message, Object... args) {
|
||||
logInfo(Branch.CONFIG, "[CONFIG] ", message, args);
|
||||
}
|
||||
|
||||
public static void renderThread(String message, Object... args) {
|
||||
logInfo(Branch.RENDER_THREAD, "[RENDER] ", message, args);
|
||||
}
|
||||
|
||||
public static void stencil(String message, Object... args) {
|
||||
logInfo(Branch.STENCIL, "[STENCIL] ", message, args);
|
||||
}
|
||||
|
||||
public static void server(String message, Object... args) {
|
||||
logInfo(Branch.SERVER, "[SERVER] ", message, args);
|
||||
}
|
||||
|
||||
public static void warn(String message, Object... args) {
|
||||
if (!warnEnabled()) return;
|
||||
log(LogLevel.WARN, "[WARN] ", message, args);
|
||||
}
|
||||
|
||||
public static void infoOnce(String key, String message, Object... args) {
|
||||
if (once(key)) info(message, args);
|
||||
}
|
||||
|
||||
public static void configOnce(String key, String message, Object... args) {
|
||||
if (once(key)) config(message, args);
|
||||
}
|
||||
|
||||
public static void renderThreadOnce(String key, String message, Object... args) {
|
||||
if (once(key)) renderThread(message, args);
|
||||
}
|
||||
|
||||
public static void stencilOnce(String key, String message, Object... args) {
|
||||
if (once(key)) stencil(message, args);
|
||||
}
|
||||
|
||||
public static void warnOnce(String key, String message, Object... args) {
|
||||
if (once(key)) warn(message, args);
|
||||
}
|
||||
|
||||
public static void errorOnce(String key, String message, Object... args) {
|
||||
if (once(key)) error(message, null, args);
|
||||
}
|
||||
|
||||
public static void infoOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) info(message, args);
|
||||
}
|
||||
|
||||
public static void configOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) config(message, args);
|
||||
}
|
||||
|
||||
public static void renderThreadOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) renderThread(message, args);
|
||||
}
|
||||
|
||||
public static void stencilOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) stencil(message, args);
|
||||
}
|
||||
|
||||
public static void warnOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) warn(message, args);
|
||||
}
|
||||
|
||||
public static void errorOnChange(String key, Object state, String message, Object... args) {
|
||||
if (changed(key, state)) error(message, null, args);
|
||||
}
|
||||
|
||||
public static void error(String message) {
|
||||
error(message, null);
|
||||
}
|
||||
|
||||
public static void error(String message, Throwable t) {
|
||||
log(LogLevel.ERROR, "[ERROR] ", message);
|
||||
if (isEnabled() && t != null) {
|
||||
LOGGER.error("[LoVisual][Debug] exception", t);
|
||||
}
|
||||
}
|
||||
|
||||
public static void error(String message, Throwable t, Object... args) {
|
||||
log(LogLevel.ERROR, "[ERROR] ", message, args);
|
||||
if (isEnabled() && t != null) {
|
||||
LOGGER.error("[LoVisual][Debug] exception", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void logInfo(Branch branch, String prefix, String message, Object... args) {
|
||||
if (!branchEnabled(branch)) return;
|
||||
log(LogLevel.INFO, prefix, message, args);
|
||||
}
|
||||
|
||||
private static boolean warnEnabled() {
|
||||
return mode != DebugMode.OFF && mode != DebugMode.ERROR_ONLY;
|
||||
}
|
||||
|
||||
private static boolean branchEnabled(Branch branch) {
|
||||
return switch (mode) {
|
||||
case OFF, ERROR_ONLY, ERROR_AND_WARNINGS -> false;
|
||||
case INFO -> branch == Branch.GENERAL;
|
||||
case CONFIG -> branch == Branch.CONFIG;
|
||||
case RENDER_THREAD -> branch == Branch.RENDER_THREAD;
|
||||
case STENCIL -> branch == Branch.STENCIL;
|
||||
case SERVERDEBUG -> branch == Branch.SERVER;
|
||||
case ALL -> true;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean once(String key) {
|
||||
String safeKey = key == null ? "<null>" : key;
|
||||
return ONCE_KEYS.add(safeKey);
|
||||
}
|
||||
|
||||
private static boolean changed(String key, Object state) {
|
||||
String safeKey = key == null ? "<null>" : key;
|
||||
String next = String.valueOf(state);
|
||||
String previous = LAST_STATES.put(safeKey, next);
|
||||
return !next.equals(previous);
|
||||
}
|
||||
|
||||
private static void log(LogLevel level, String prefix, String message, Object... args) {
|
||||
if (!isEnabled()) return;
|
||||
Objects.requireNonNull(message, "message");
|
||||
|
||||
String formatted = (args == null || args.length == 0)
|
||||
? message
|
||||
: String.format(message, args);
|
||||
|
||||
String out = "[LoVisual][Debug] " + prefix + formatted;
|
||||
switch (level) {
|
||||
case INFO -> LOGGER.info(out);
|
||||
case WARN -> LOGGER.warn(out);
|
||||
case ERROR -> LOGGER.error(out);
|
||||
}
|
||||
}
|
||||
|
||||
private enum Branch {
|
||||
GENERAL,
|
||||
CONFIG,
|
||||
RENDER_THREAD,
|
||||
STENCIL,
|
||||
SERVER
|
||||
}
|
||||
|
||||
private enum LogLevel {
|
||||
INFO,
|
||||
WARN,
|
||||
ERROR
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.logging;
|
||||
|
||||
public enum DebugMode {
|
||||
OFF,
|
||||
ERROR_ONLY,
|
||||
ERROR_AND_WARNINGS,
|
||||
INFO,
|
||||
CONFIG,
|
||||
RENDER_THREAD,
|
||||
STENCIL,
|
||||
SERVERDEBUG,
|
||||
ALL
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* 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.logging;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.scores.Objective;
|
||||
import net.minecraft.world.scores.PlayerScoreEntry;
|
||||
import net.minecraft.world.scores.PlayerTeam;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public enum ServerDumpUtil {
|
||||
;
|
||||
|
||||
private static final Map<String, String> LAST_SIGNATURES = new ConcurrentHashMap<>();
|
||||
|
||||
public static void dumpTabText(String section, Component text) {
|
||||
if (text == null || !DebugLog.serverOnly()) return;
|
||||
|
||||
String raw = text.getString();
|
||||
if (raw.isBlank()) return;
|
||||
|
||||
String key = "tab:" + section;
|
||||
if (!markChanged(key, raw)) return;
|
||||
|
||||
DebugLog.server("==== TAB %s ====", section);
|
||||
for (String line : raw.split("\\R")) {
|
||||
DebugLog.server("[TAB] %s", line);
|
||||
}
|
||||
DebugLog.server("========================");
|
||||
}
|
||||
|
||||
public static void dumpScoreboardSidebar(Objective objective,
|
||||
List<PlayerScoreEntry> entries,
|
||||
List<String> renderedLabels,
|
||||
List<String> renderedValues) {
|
||||
if (objective == null || !DebugLog.serverOnly()) return;
|
||||
|
||||
StringBuilder signature = new StringBuilder();
|
||||
signature.append(objective.getName()).append('|')
|
||||
.append(objective.getDisplayName().getString()).append('|');
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
PlayerScoreEntry entry = entries.get(i);
|
||||
signature.append(i).append(':')
|
||||
.append(entry.value()).append(':')
|
||||
.append(entry.owner()).append(':')
|
||||
.append(entry.ownerName().getString()).append('\n');
|
||||
}
|
||||
|
||||
if (!markChanged("scoreboard:sidebar", signature.toString())) return;
|
||||
|
||||
DebugLog.server("[ScoreboardDump] objectiveName=%s display=\"%s\" entries=%d",
|
||||
objective.getName(), objective.getDisplayName().getString(), entries.size());
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
PlayerScoreEntry entry = entries.get(i);
|
||||
PlayerTeam team = objective.getScoreboard().getPlayersTeam(entry.owner());
|
||||
Component decorated = PlayerTeam.formatNameForTeam(team, entry.ownerName());
|
||||
String renderedLabel = i < renderedLabels.size() ? renderedLabels.get(i) : "<skipped>";
|
||||
String renderedValue = i < renderedValues.size() ? renderedValues.get(i) : "<skipped>";
|
||||
DebugLog.server("[ScoreboardDump] [%d] score=%d owner=\"%s\" rawName=\"%s\" decorated=\"%s\" label=\"%s\" value=\"%s\"",
|
||||
i,
|
||||
entry.value(),
|
||||
entry.owner(),
|
||||
entry.ownerName().getString(),
|
||||
decorated.getString(),
|
||||
renderedLabel,
|
||||
renderedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean markChanged(String key, String signature) {
|
||||
String previous = LAST_SIGNATURES.put(key, signature);
|
||||
return !signature.equals(previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
/*
|
||||
* 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.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.bars.CustomBar;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public enum MapPlayersCache {
|
||||
;
|
||||
private static final long REFRESH_MS = 10_000L;
|
||||
private static final AtomicBoolean FETCHING = new AtomicBoolean(false);
|
||||
private static volatile Set<UUID> cachedPlayers = Collections.emptySet();
|
||||
private static volatile long lastFetchMs = 0L;
|
||||
private static volatile String lastUrl = null;
|
||||
|
||||
public static Set<UUID> getPlayers(Minecraft mc, CustomBar bm) {
|
||||
if (mc == null || bm == null) return Collections.emptySet();
|
||||
if (!bm.isLocatorMapFilterEnabled()) return Collections.emptySet();
|
||||
|
||||
String serverHost = bm.getLocatorMapServer();
|
||||
if (serverHost == null || serverHost.isBlank()) return Collections.emptySet();
|
||||
if (!isOnServer(mc, serverHost)) return Collections.emptySet();
|
||||
|
||||
String baseUrl = bm.getLocatorMapUrl();
|
||||
if (baseUrl == null || baseUrl.isBlank()) return Collections.emptySet();
|
||||
|
||||
String playersUrl = normalizePlayersUrl(baseUrl);
|
||||
if (playersUrl == null || playersUrl.isBlank()) return Collections.emptySet();
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (!playersUrl.equals(lastUrl)) {
|
||||
lastUrl = playersUrl;
|
||||
cachedPlayers = Collections.emptySet();
|
||||
lastFetchMs = 0L;
|
||||
}
|
||||
|
||||
if (now - lastFetchMs >= REFRESH_MS && FETCHING.compareAndSet(false, true)) {
|
||||
DebugLog.server("Locator map fetch start: %s", playersUrl);
|
||||
CompletableFuture.runAsync(() -> fetch(playersUrl));
|
||||
}
|
||||
|
||||
return cachedPlayers;
|
||||
}
|
||||
|
||||
private static void fetch(String playersUrl) {
|
||||
try {
|
||||
String json = httpGet(playersUrl);
|
||||
if (json == null || json.isBlank()) {
|
||||
DebugLog.server("Locator map fetch empty: %s", playersUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
Set<UUID> next = parsePlayers(json);
|
||||
cachedPlayers = next;
|
||||
DebugLog.server("Locator map fetch ok: %d player(s)", next.size());
|
||||
} catch (Exception ignored) {
|
||||
DebugLog.server("Locator map fetch failed: %s", playersUrl);
|
||||
} finally {
|
||||
lastFetchMs = System.currentTimeMillis();
|
||||
FETCHING.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static String httpGet(String urlText) throws Exception {
|
||||
URL url = URI.create(urlText).toURL();
|
||||
HttpURLConnection request = (HttpURLConnection) url.openConnection();
|
||||
request.setRequestMethod("GET");
|
||||
request.setRequestProperty("Content-Type", "application/json");
|
||||
request.setInstanceFollowRedirects(true);
|
||||
request.setConnectTimeout(10_000);
|
||||
request.setReadTimeout(10_000);
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
private static Set<UUID> parsePlayers(String json) {
|
||||
try {
|
||||
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
|
||||
JsonArray players = root.has("players") ? root.getAsJsonArray("players") : new JsonArray();
|
||||
if (players == null || players.isEmpty()) return Collections.emptySet();
|
||||
|
||||
Set<UUID> ids = new HashSet<>();
|
||||
for (JsonElement el : players) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject p = el.getAsJsonObject();
|
||||
if (!p.has("uuid")) continue;
|
||||
String raw = p.get("uuid").getAsString();
|
||||
UUID uuid = parseUuid(raw);
|
||||
if (uuid != null) ids.add(uuid);
|
||||
}
|
||||
return ids;
|
||||
} catch (Exception ignored) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID parseUuid(String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
try {
|
||||
if (s.indexOf('-') >= 0) {
|
||||
return UUID.fromString(s);
|
||||
}
|
||||
if (s.length() == 32) {
|
||||
String dashed = s.substring(0, 8) + "-" + s.substring(8, 12) + "-" + s.substring(12, 16)
|
||||
+ "-" + s.substring(16, 20) + "-" + s.substring(20);
|
||||
return UUID.fromString(dashed);
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizePlayersUrl(String baseUrl) {
|
||||
String b = baseUrl.trim();
|
||||
if (b.isEmpty()) return "";
|
||||
if (b.endsWith("/")) b = b.substring(0, b.length() - 1);
|
||||
|
||||
if (b.endsWith("players.json")) {
|
||||
return b;
|
||||
}
|
||||
if (b.endsWith("/tiles")) {
|
||||
return b + "/players.json";
|
||||
}
|
||||
return b + "/tiles/players.json";
|
||||
}
|
||||
|
||||
private static boolean isOnServer(Minecraft mc, String serverHost) {
|
||||
if (mc == null || mc.hasSingleplayerServer()) return false;
|
||||
ServerData info = mc.getCurrentServer();
|
||||
if (info == null || info.ip == null) return false;
|
||||
String current = stripPort(info.ip);
|
||||
return current.equalsIgnoreCase(serverHost.trim());
|
||||
}
|
||||
|
||||
private static String stripPort(String address) {
|
||||
if (address == null) return "";
|
||||
String s = address.trim();
|
||||
if (s.isEmpty()) return "";
|
||||
|
||||
if (s.startsWith("[")) {
|
||||
int end = s.indexOf(']');
|
||||
if (end > 1) {
|
||||
return s.substring(1, end);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
int lastColon = s.lastIndexOf(':');
|
||||
if (lastColon > 0 && lastColon + 1 < s.length()) {
|
||||
String tail = s.substring(lastColon + 1);
|
||||
boolean digits = true;
|
||||
for (int i = 0; i < tail.length(); i++) {
|
||||
char c = tail.charAt(i);
|
||||
if (c < '0' || c > '9') {
|
||||
digits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (digits) {
|
||||
return s.substring(0, lastColon);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,25 @@
|
|||
/*
|
||||
* 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.media.impl;
|
||||
|
||||
import dev.loki.lovisual.util.media.session.IMediaSession;
|
||||
import dev.loki.lovisual.util.media.info.MediaPlayerInfo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class DummyMediaPlayerInfo implements MediaPlayerInfo {
|
||||
public static final DummyMediaPlayerInfo INSTANCE = new DummyMediaPlayerInfo();
|
||||
|
||||
private DummyMediaPlayerInfo() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IMediaSession> getMediaSessions() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue