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:
loki5512344 2026-09-23 22:38:08 +02:00
parent ec10395511
commit 9d08fa910a
2239 changed files with 35265 additions and 28110 deletions

View file

@ -0,0 +1,387 @@
/*
* 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.player.effect.heuristics;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Holder;
import net.minecraft.core.particles.ColorParticleOption;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.mixins.accessors.state.EffectParticleEffectAccessor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectInference;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectTracker;
/**
* Heuristics to guess effects from colored entity-effect particles.
*/
public enum StatusEffectHeuristics {
;
private static final java.util.Map<Integer, java.util.Map<Holder<MobEffect>, Long>> LAST_SEEN = new java.util.HashMap<>();
private static final long HEURISTIC_TIMEOUT_MS = 10000L;
// Dynamic palette of effects + colors from the registry to survive version bumps.
private static final List<Holder<MobEffect>> PALETTE = buildPalette();
private static final int[] COLORS = computeColors(PALETTE);
private static final int[] NORMALIZED_COLORS = computeNormalizedColors(COLORS);
private static final Set<Holder<MobEffect>> PRIORITY_EFFECTS = Set.of(
MobEffects.STRENGTH,
MobEffects.RESISTANCE
);
private static final Set<Holder<MobEffect>> FORCE_PICK_EFFECTS = Set.of(
MobEffects.STRENGTH,
MobEffects.RESISTANCE
);
// Loose thresholds: particle tint varies a lot across versions; keep wide but still reject wild mismatches.
private static final int COLOR_THRESHOLD = 42000;
private static final int PRIORITY_THRESHOLD = 120000; // allow rougher matches for hard-to-distinguish colors
private static final int AMBIGUITY_DELTA = 2000;
private static final double AMBIGUITY_RATIO = 1.05; // accept best if second-best is clearly worse
public static void handle(ParticleOptions particle, double px, double py, double pz) {
Holder<MobEffect> direct = directEffectFromParticle(particle);
if (direct != null) {
registerHeuristic(direct, px, py, pz);
return;
}
int color = extractColor(particle);
if (color == 0) return;
// Normalize brightness so darker/lighter particles still map to the same hue bucket.
int normalizedColor = normalizeColor(color);
Nearest nearest = nearest(normalizedColor, NORMALIZED_COLORS);
boolean priority = isPriority(nearest.idx); // e.g. strength/resistance
boolean ambiguous = nearest.secondDiff - nearest.diff < AMBIGUITY_DELTA;
// Prefer fire resistance when it's one of the ambiguous pair so we don't mislabel it as strength.
int chosenIdx = nearest.idx;
int chosenDiff = nearest.diff;
// Strength vs fire-resistance: both share orange/red; bias using channel dominance.
if ((isStrength(nearest.idx) && isFire(nearest.secondIdx)) || (isStrength(nearest.secondIdx) && isFire(nearest.idx))) {
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
// Strength tint is deep red; fire resistance skews orange (more green). Bias toward fire only when clearly orange.
float hue = hueDegrees(r, g, b);
boolean looksFire = (g >= r * 0.8f) && hue >= 25f;
if (looksFire) {
chosenIdx = isFire(nearest.idx) ? nearest.idx : nearest.secondIdx;
chosenDiff = isFire(nearest.idx) ? nearest.diff : nearest.secondDiff;
} else {
chosenIdx = isStrength(nearest.idx) ? nearest.idx : nearest.secondIdx;
chosenDiff = isStrength(nearest.idx) ? nearest.diff : nearest.secondDiff;
}
priority = isPriority(chosenIdx);
ambiguous = false; // resolved manually
} else if (ambiguous && (isFire(nearest.idx) ^ isFire(nearest.secondIdx))) {
chosenIdx = isFire(nearest.idx) ? nearest.idx : nearest.secondIdx;
chosenDiff = isFire(nearest.idx) ? nearest.diff : nearest.secondDiff;
priority = isPriority(chosenIdx);
ambiguous = false;
} else if (ambiguous && !priority && isPriority(nearest.secondIdx)) {
chosenIdx = nearest.secondIdx;
chosenDiff = nearest.secondDiff;
priority = true;
ambiguous = false; // force pick priority
}
// For priority effects (strength/resistance) allow even if ambiguous, with wide threshold.
// Treat ambiguous matches as acceptable when the best is clearly closer than the runner-up (ratio check).
if (ambiguous && !priority) {
double ratio = nearest.secondDiff / (double) Math.max(1, nearest.diff);
if (ratio < AMBIGUITY_RATIO) {
return;
}
}
boolean allowed = priority
? chosenDiff < PRIORITY_THRESHOLD
: chosenDiff < COLOR_THRESHOLD;
if (chosenIdx < 0 || chosenIdx >= PALETTE.size() || !allowed) {
return;
}
Holder<MobEffect> guessed = PALETTE.get(chosenIdx);
if (isNightVision(chosenIdx)) return; // do not infer night vision heuristically
registerHeuristic(guessed, px, py, pz);
}
private static void registerHeuristic(Holder<MobEffect> guessed, double px, double py, double pz) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null) return;
List<? extends Player> players = mc.level.players();
double bestDist = Double.MAX_VALUE;
Player target = null;
Vec3 pos = new Vec3(px, py, pz);
for (Player p : players) {
double d = p.position().distanceTo(pos);
if (d < bestDist) {
bestDist = d;
target = p;
}
}
if (target == null) return;
if (mc.player != null && target.getId() == mc.player.getId()) return;
if (!StatusEffectInference.isParticleNearPlayer(target, pos)) return;
long now = System.currentTimeMillis();
// Always refresh last seen so we don't drop while particles keep coming.
markSeen(target.getId(), guessed, now);
boolean has = StatusEffectTracker.has(target.getId(), guessed);
boolean hideCurrent = StatusEffectTracker.shouldHideDuration(target.getId(), guessed);
if (has && !hideCurrent) {
return; // already tracked with a visible duration, just refreshed last-seen
}
StatusEffectInference.ResolvedEffect resolved = null;
if (!has || hideCurrent) {
resolved = StatusEffectInference.resolve(target, guessed, pos, false);
if (has && (resolved == null || !resolved.showDuration())) {
return; // already tracked, and no duration upgrade available
}
}
// Heuristic lifetime is handled by stale timeout; set infinite here.
int heuristicDurationTicks = -1;
int amplifier = 0;
boolean hideDuration = true;
if (resolved != null) {
resolved = StatusEffectInference.resolve(target, guessed, pos, true);
if (resolved != null) {
amplifier = resolved.amplifier();
heuristicDurationTicks = resolved.duration();
hideDuration = !resolved.showDuration();
}
}
StatusEffectTracker.apply(target.getId(), guessed, amplifier, heuristicDurationTicks, false, true, true, true, hideDuration);
}
private static boolean isPriority(int idx) {
Holder<MobEffect> e = at(idx);
if (e == null) return false;
return PRIORITY_EFFECTS.contains(e);
}
private static boolean isFire(int idx) {
Holder<MobEffect> e = at(idx);
if (e == null) return false;
return e.equals(MobEffects.FIRE_RESISTANCE);
}
private static boolean isStrength(int idx) {
Holder<MobEffect> e = at(idx);
if (e == null) return false;
return e.equals(MobEffects.STRENGTH);
}
// If ambiguous, force-pick these instead of skipping
private static boolean isForcePick(int idx) {
Holder<MobEffect> e = at(idx);
if (e == null) return false;
return FORCE_PICK_EFFECTS.contains(e);
}
private static boolean isNightVision(int idx) {
Holder<MobEffect> e = at(idx);
if (e == null) return false;
return e.equals(MobEffects.NIGHT_VISION);
}
private static void markSeen(int entityId, Holder<MobEffect> effect, long time) {
var map = LAST_SEEN.computeIfAbsent(entityId, k -> new java.util.HashMap<>());
map.put(effect, time);
}
/**
* Called from tracker tick to drop heuristic effects if we stopped seeing particles.
*/
public static void pruneStale(long nowMs) {
if (LAST_SEEN.isEmpty()) return;
var removeEntities = new java.util.ArrayList<Integer>();
for (var entry : LAST_SEEN.entrySet()) {
int eid = entry.getKey();
var effects = entry.getValue();
var toRemove = new java.util.ArrayList<Holder<MobEffect>>();
for (var e : effects.entrySet()) {
if (nowMs - e.getValue() > HEURISTIC_TIMEOUT_MS) {
StatusEffectTracker.remove(eid, e.getKey());
toRemove.add(e.getKey());
}
}
toRemove.forEach(effects::remove);
if (effects.isEmpty()) removeEntities.add(eid);
}
removeEntities.forEach(LAST_SEEN::remove);
}
private static int[] computeColors(List<Holder<MobEffect>> palette) {
int[] colors = new int[palette.size()];
for (int i = 0; i < palette.size(); i++) {
colors[i] = palette.get(i).value().getColor();
}
return colors;
}
private static int[] computeNormalizedColors(int[] colors) {
int[] normalized = new int[colors.length];
for (int i = 0; i < colors.length; i++) {
normalized[i] = normalizeColor(colors[i]);
}
return normalized;
}
private static int normalizeColor(int color) {
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
int max = Math.max(r, Math.max(g, b));
if (max <= 0) return 0;
float scale = 255f / (float) max;
int nr = Math.min(255, Math.round(r * scale));
int ng = Math.min(255, Math.round(g * scale));
int nb = Math.min(255, Math.round(b * scale));
return (nr << 16) | (ng << 8) | nb;
}
// Basic HSV hue calculation (degrees 0-360)
private static float hueDegrees(int r, int g, int b) {
float rf = r / 255f;
float gf = g / 255f;
float bf = b / 255f;
float max = Math.max(rf, Math.max(gf, bf));
float min = Math.min(rf, Math.min(gf, bf));
float delta = max - min;
if (delta <= 0f) return 0f;
float hue;
if (max == rf) {
hue = (gf - bf) / delta;
} else if (max == gf) {
hue = 2f + (bf - rf) / delta;
} else {
hue = 4f + (rf - gf) / delta;
}
hue *= 60f;
if (hue < 0f) hue += 360f;
return hue;
}
private static Nearest nearest(int color, int[] palette) {
int best = -1, second = -1;
int bestDiff = Integer.MAX_VALUE;
int secondDiff = Integer.MAX_VALUE;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
for (int i = 0; i < palette.length; i++) {
int c = palette[i];
int dr = ((c >> 16) & 0xFF) - r;
int dg = ((c >> 8) & 0xFF) - g;
int db = (c & 0xFF) - b;
int diff = dr * dr + dg * dg + db * db;
if (diff < bestDiff) {
secondDiff = bestDiff;
second = best;
bestDiff = diff;
best = i;
} else if (diff < secondDiff) {
secondDiff = diff;
second = i;
}
}
// require reasonably close match
return new Nearest(best, bestDiff, second, secondDiff);
}
private static Holder<MobEffect> at(int idx) {
if (idx < 0 || idx >= PALETTE.size()) return null;
return PALETTE.get(idx);
}
private static List<Holder<MobEffect>> buildPalette() {
try {
return BuiltInRegistries.MOB_EFFECT.listElements().collect(Collectors.toCollection(ArrayList::new));
} catch (Exception e) {
// Fallback to static list to avoid nulls during very early load
return Arrays.asList(
MobEffects.SPEED,
MobEffects.SLOWNESS,
MobEffects.HASTE,
MobEffects.MINING_FATIGUE,
MobEffects.STRENGTH,
MobEffects.JUMP_BOOST,
MobEffects.NAUSEA,
MobEffects.REGENERATION,
MobEffects.RESISTANCE,
MobEffects.FIRE_RESISTANCE,
MobEffects.WATER_BREATHING,
MobEffects.INVISIBILITY,
MobEffects.BLINDNESS,
MobEffects.NIGHT_VISION,
MobEffects.HUNGER,
MobEffects.WEAKNESS,
MobEffects.POISON,
MobEffects.SLOW_FALLING,
MobEffects.DOLPHINS_GRACE,
MobEffects.CONDUIT_POWER,
MobEffects.HERO_OF_THE_VILLAGE,
MobEffects.WITHER,
MobEffects.HEALTH_BOOST,
MobEffects.SATURATION,
MobEffects.LUCK,
MobEffects.UNLUCK,
MobEffects.GLOWING,
MobEffects.LEVITATION,
MobEffects.BAD_OMEN,
MobEffects.DARKNESS,
MobEffects.TRIAL_OMEN,
MobEffects.RAID_OMEN,
MobEffects.WIND_CHARGED,
MobEffects.WEAVING,
MobEffects.OOZING,
MobEffects.INFESTED,
MobEffects.BREATH_OF_THE_NAUTILUS
);
}
}
private static Holder<MobEffect> directEffectFromParticle(ParticleOptions particle) {
if (particle == ParticleTypes.RAID_OMEN) return MobEffects.RAID_OMEN;
if (particle == ParticleTypes.TRIAL_OMEN) return MobEffects.TRIAL_OMEN;
return null;
}
private static int extractColor(ParticleOptions particle) {
if (particle instanceof EffectParticleEffectAccessor effect) {
return effect.lovisual$getColor() & 0xFFFFFF;
}
if (particle instanceof ColorParticleOption tinted) {
int r = Math.min(255, Math.max(0, Math.round(tinted.getRed() * 255f)));
int g = Math.min(255, Math.max(0, Math.round(tinted.getGreen() * 255f)));
int b = Math.min(255, Math.max(0, Math.round(tinted.getBlue() * 255f)));
return (r << 16) | (g << 8) | b;
}
return 0;
}
private record Nearest(int idx, int diff, int secondIdx, int secondDiff) {
}
}

View file

@ -0,0 +1,322 @@
/*
* 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.player.effect.heuristics;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponents;
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.player.Player;
import net.minecraft.world.entity.projectile.throwableitemprojectile.AbstractThrownPotion;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.ItemUseAnimation;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.alchemy.PotionContents;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.item.food.FoodUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public enum StatusEffectInference {
;
private static final long CONSUME_WINDOW_MS = 3000L;
private static final long POTION_WINDOW_MS = 2500L;
private static final double PLAYER_MATCH_DIST = 6.0;
private static final double POTION_MATCH_DIST = 4.5;
private static final Map<Integer, UseState> USE_STATE = new HashMap<>();
private static final Map<Integer, List<PendingEffect>> PENDING = new HashMap<>();
private static final Map<Integer, PotionPending> POTIONS = new HashMap<>();
private static long lastTickMs;
public static void tick(Minecraft mc) {
if (mc == null || mc.level == null) return;
long now = System.currentTimeMillis();
if (now - lastTickMs < 15L) return;
lastTickMs = now;
List<? extends Player> players = mc.level.players();
java.util.HashSet<Integer> alive = new java.util.HashSet<>();
for (Player player : players) {
if (player == null) continue;
alive.add(player.getId());
if (mc.player != null && player.getId() == mc.player.getId()) continue;
updateUseState(player, now);
}
USE_STATE.keySet().removeIf(id -> !alive.contains(id));
PENDING.keySet().removeIf(id -> !alive.contains(id));
prunePending(now);
updatePotionEntities(mc, now);
}
public static boolean isParticleNearPlayer(Player player, Vec3 pos) {
if (player == null || pos == null) return false;
return player.position().distanceToSqr(pos) <= (PLAYER_MATCH_DIST * PLAYER_MATCH_DIST);
}
public static boolean isDefensiveConsumeLikely(Player player) {
if (player == null) return false;
if (isConsumableUse(player.isUsingItem(), player.getUseItem())) {
return true;
}
UseState state = USE_STATE.get(player.getId());
return state != null && isConsumableUse(state.using, state.stack);
}
public static boolean hasPendingDefensiveEffect(Player player) {
if (player == null) return false;
long now = System.currentTimeMillis();
List<PendingEffect> list = PENDING.get(player.getId());
if (list != null) {
for (PendingEffect pending : list) {
if (pending.expiresAt >= now && isDefensiveHealthEffect(pending.effect)) {
return true;
}
}
}
Vec3 pos = player.position();
for (PotionPending pending : POTIONS.values()) {
if (pending.effects.isEmpty()) continue;
if (pending.pos.distanceToSqr(pos) > POTION_MATCH_DIST * POTION_MATCH_DIST) continue;
for (PendingEffect effect : pending.effects) {
if (effect.expiresAt >= now && isDefensiveHealthEffect(effect.effect)) {
return true;
}
}
}
return false;
}
public static boolean isDefensiveHealthEffect(Holder<MobEffect> effect) {
return effect != null
&& (effect.equals(MobEffects.RESISTANCE)
|| effect.equals(MobEffects.REGENERATION)
|| effect.equals(MobEffects.ABSORPTION)
|| effect.equals(MobEffects.HEALTH_BOOST)
|| effect.equals(MobEffects.SATURATION));
}
public static ResolvedEffect resolve(Player target,
Holder<MobEffect> effect,
Vec3 particlePos) {
return resolve(target, effect, particlePos, true);
}
public static ResolvedEffect resolve(Player target,
Holder<MobEffect> effect,
Vec3 particlePos,
boolean consume) {
if (target == null || effect == null) return null;
long now = System.currentTimeMillis();
PendingEffect direct = findPending(target.getId(), effect, now, consume);
if (direct != null) {
int duration = direct.showDuration ? direct.duration : -1;
return new ResolvedEffect(direct.amplifier, duration, direct.showDuration);
}
PendingEffect fromPotion = findPotionPending(effect, particlePos);
if (fromPotion == null) return null;
return new ResolvedEffect(fromPotion.amplifier, -1, false);
}
private static void updateUseState(Player player, long now) {
int id = player.getId();
UseState state = USE_STATE.computeIfAbsent(id, k -> new UseState());
boolean usingNow = player.isUsingItem();
if (state.using && !usingNow) {
ItemStack stack = state.stack;
ItemUseAnimation action = state.action;
if (stack != null && !stack.isEmpty()) {
handleConsume(id, stack, action, now);
}
}
state.using = usingNow;
if (usingNow) {
ItemStack active = player.getUseItem();
state.stack = active == null ? ItemStack.EMPTY : active.copy();
state.action = active == null ? ItemUseAnimation.NONE : active.getUseAnimation();
} else {
state.stack = ItemStack.EMPTY;
state.action = ItemUseAnimation.NONE;
}
}
private static void handleConsume(int entityId, ItemStack stack, ItemUseAnimation action, long now) {
if (stack == null || stack.isEmpty()) return;
boolean drink = action == ItemUseAnimation.DRINK;
boolean eat = action == ItemUseAnimation.EAT;
if (!drink && !eat) return;
if (drink) {
List<MobEffectInstance> potion = readPotionEffects(stack);
boolean splash = stack.is(Items.SPLASH_POTION) || stack.is(Items.LINGERING_POTION);
if (!potion.isEmpty()) {
addPending(entityId, potion, !splash, now);
return;
}
}
if (eat || FoodUtil.isFood(stack)) {
List<MobEffectInstance> food = FoodUtil.getFoodEffects(stack);
if (!food.isEmpty()) {
addPending(entityId, food, true, now);
}
}
}
private static boolean isConsumableUse(boolean using, ItemStack stack) {
if (!using || stack == null || stack.isEmpty()) return false;
ItemUseAnimation action = stack.getUseAnimation();
return action == ItemUseAnimation.DRINK || action == ItemUseAnimation.EAT || FoodUtil.isFood(stack);
}
private static void addPending(int entityId,
List<MobEffectInstance> effects,
boolean showDuration,
long now) {
if (effects == null || effects.isEmpty()) return;
long expiresAt = now + CONSUME_WINDOW_MS;
List<PendingEffect> list = new ArrayList<>(PENDING.getOrDefault(entityId, List.of()));
for (MobEffectInstance inst : effects) {
if (inst == null) continue;
list.add(new PendingEffect(
inst.getEffect(),
inst.getAmplifier(),
inst.getDuration(),
showDuration,
expiresAt
));
}
if (list.isEmpty()) {
PENDING.remove(entityId);
} else {
PENDING.put(entityId, list);
}
}
private static PendingEffect findPending(int entityId,
Holder<MobEffect> effect,
long now,
boolean consume) {
List<PendingEffect> list = PENDING.get(entityId);
if (list == null || list.isEmpty()) return null;
PendingEffect best = null;
for (PendingEffect entry : list) {
if (entry.expiresAt < now) continue;
if (!entry.effect.equals(effect)) continue;
if (best == null || entry.expiresAt > best.expiresAt) {
best = entry;
}
}
if (best != null && consume) {
list.remove(best);
if (list.isEmpty()) {
PENDING.remove(entityId);
} else {
PENDING.put(entityId, list);
}
}
return best;
}
private static PendingEffect findPotionPending(Holder<MobEffect> effect, Vec3 pos) {
if (pos == null) return null;
double bestDist2 = Double.MAX_VALUE;
PendingEffect best = null;
for (PotionPending pending : POTIONS.values()) {
if (pending.effects.isEmpty()) continue;
double dist2 = pending.pos.distanceToSqr(pos);
if (dist2 > POTION_MATCH_DIST * POTION_MATCH_DIST) continue;
for (PendingEffect entry : pending.effects) {
if (!entry.effect.equals(effect)) continue;
if (dist2 < bestDist2) {
bestDist2 = dist2;
best = entry;
}
}
}
return best;
}
private static void prunePending(long now) {
if (PENDING.isEmpty()) return;
List<Integer> empty = new ArrayList<>();
for (var entry : PENDING.entrySet()) {
List<PendingEffect> list = entry.getValue();
list.removeIf(e -> e.expiresAt < now);
if (list.isEmpty()) empty.add(entry.getKey());
}
for (int id : empty) PENDING.remove(id);
}
private static void updatePotionEntities(Minecraft mc, long now) {
if (mc.level == null) return;
boolean any = false;
for (Entity entity : mc.level.entitiesForRendering()) {
if (!(entity instanceof AbstractThrownPotion potion)) continue;
ItemStack stack = potion.getItem();
if (stack == null || stack.isEmpty()) continue;
List<MobEffectInstance> effects = readPotionEffects(stack);
if (effects.isEmpty()) continue;
List<PendingEffect> list = new ArrayList<>();
for (MobEffectInstance inst : effects) {
list.add(new PendingEffect(inst.getEffect(), inst.getAmplifier(), inst.getDuration(), false, now + POTION_WINDOW_MS));
}
POTIONS.put(entity.getId(), new PotionPending(entity.position(), list, now));
any = true;
}
if (!any && !POTIONS.isEmpty()) {
// keep previous entries but let them expire
}
prunePotions(now);
}
private static void prunePotions(long now) {
if (POTIONS.isEmpty()) return;
POTIONS.entrySet().removeIf(e -> (now - e.getValue().seenAt) > POTION_WINDOW_MS);
}
private static List<MobEffectInstance> readPotionEffects(ItemStack stack) {
if (stack == null || stack.isEmpty()) return List.of();
PotionContents contents = stack.get(DataComponents.POTION_CONTENTS);
if (contents == null) return List.of();
List<MobEffectInstance> out = new ArrayList<>();
for (MobEffectInstance inst : contents.getAllEffects()) {
if (inst == null) continue;
out.add(new MobEffectInstance(inst));
}
return out;
}
private static final class UseState {
private boolean using;
private ItemStack stack = ItemStack.EMPTY;
private ItemUseAnimation action = ItemUseAnimation.NONE;
}
private record PendingEffect(Holder<MobEffect> effect,
int amplifier,
int duration,
boolean showDuration,
long expiresAt) {
}
private record PotionPending(Vec3 pos, List<PendingEffect> effects, long seenAt) {
}
public record ResolvedEffect(int amplifier, int duration, boolean showDuration) {
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.player.effect.tracker;
import net.minecraft.core.Holder;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.LivingEntity;
import java.util.ArrayList;
import java.util.List;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectTracker;
/**
* Unified access to merged status effects: tracker first, raw entity data as fallback.
*/
public enum StatusEffectAccess {
;
public static boolean has(LivingEntity entity, Holder<MobEffect> effect) {
return get(entity, effect) != null;
}
public static MobEffectInstance get(LivingEntity entity, Holder<MobEffect> effect) {
if (entity == null || effect == null) {
return null;
}
for (MobEffectInstance instance : StatusEffectTracker.get(entity.getId())) {
if (instance.getEffect().equals(effect)) {
return instance;
}
}
return entity.getEffect(effect);
}
public static List<MobEffectInstance> all(LivingEntity entity) {
if (entity == null) {
return List.of();
}
List<MobEffectInstance> tracked = StatusEffectTracker.get(entity.getId());
if (!tracked.isEmpty()) {
return tracked;
}
return new ArrayList<>(entity.getActiveEffects());
}
}

View file

@ -0,0 +1,272 @@
/*
* 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.player.effect.tracker;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Holder;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import dev.loki.lovisual.util.logging.DebugLog;
import java.util.*;
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectHeuristics;
/**
* Client-side cache of status effects for entities, synced from live entity data
* and optionally extended by heuristic particle detection.
*/
public enum StatusEffectTracker {
;
private static final Map<Integer, List<MobEffectInstance>> EFFECTS = new HashMap<>();
private static final Map<Integer, List<Holder<MobEffect>>> HEURISTIC = new HashMap<>();
private static final Map<Integer, List<Holder<MobEffect>>> HEURISTIC_HIDE_DURATION = new HashMap<>();
public static void syncEntity(int entityId, Iterable<MobEffectInstance> effects) {
List<MobEffectInstance> actual = new ArrayList<>();
if (effects != null) {
for (MobEffectInstance inst : effects) {
if (inst == null) continue;
actual.add(copyInstance(inst));
}
}
List<Holder<MobEffect>> heur = HEURISTIC.get(entityId);
if (heur != null && !heur.isEmpty()) {
heur.removeIf(effect -> containsEffect(actual, effect));
if (heur.isEmpty()) {
HEURISTIC.remove(entityId);
} else {
HEURISTIC.put(entityId, heur);
}
List<Holder<MobEffect>> hide = HEURISTIC_HIDE_DURATION.get(entityId);
if (hide != null) {
hide.removeIf(effect -> !heur.contains(effect));
if (hide.isEmpty()) {
HEURISTIC_HIDE_DURATION.remove(entityId);
} else {
HEURISTIC_HIDE_DURATION.put(entityId, hide);
}
}
}
if (heur == null || heur.isEmpty()) {
HEURISTIC_HIDE_DURATION.remove(entityId);
}
if (heur != null && !heur.isEmpty()) {
List<MobEffectInstance> previous = EFFECTS.get(entityId);
for (Holder<MobEffect> effect : heur) {
if (containsEffect(actual, effect)) continue;
MobEffectInstance cached = findInstance(previous, effect);
actual.add(cached != null ? cached : new MobEffectInstance(effect, -1, 0, false, true, true));
}
}
if (actual.isEmpty()) {
EFFECTS.remove(entityId);
} else {
EFFECTS.put(entityId, actual);
}
}
public static void apply(int entityId,
Holder<MobEffect> effect,
int amplifier,
int duration,
boolean ambient,
boolean showParticles,
boolean showIcon) {
apply(entityId, effect, amplifier, duration, ambient, showParticles, showIcon, false);
}
public static void apply(int entityId,
Holder<MobEffect> effect,
int amplifier,
int duration,
boolean ambient,
boolean showParticles,
boolean showIcon,
boolean heuristic) {
apply(entityId, effect, amplifier, duration, ambient, showParticles, showIcon, heuristic, true);
}
public static void apply(int entityId,
Holder<MobEffect> effect,
int amplifier,
int duration,
boolean ambient,
boolean showParticles,
boolean showIcon,
boolean heuristic,
boolean hideDuration) {
DebugLog.info("Effect apply eid=%d (%s) %s amp=%d dur=%d ambient=%s particles=%s icon=%s",
entityId,
resolveName(entityId),
effect.unwrapKey().map(k -> k.identifier().toString()).orElse("unknown"),
amplifier,
duration,
ambient,
showParticles,
showIcon
);
List<MobEffectInstance> list = new ArrayList<>(EFFECTS.getOrDefault(entityId, Collections.emptyList()));
list.removeIf(inst -> inst.getEffect().equals(effect));
list.add(new MobEffectInstance(effect, duration, amplifier, ambient, showParticles, showIcon));
EFFECTS.put(entityId, list);
List<Holder<MobEffect>> heur = new ArrayList<>(HEURISTIC.getOrDefault(entityId, Collections.emptyList()));
heur.removeIf(e -> e.equals(effect));
if (heuristic) heur.add(effect);
if (heur.isEmpty()) {
HEURISTIC.remove(entityId);
} else {
HEURISTIC.put(entityId, heur);
}
List<Holder<MobEffect>> hide = new ArrayList<>(HEURISTIC_HIDE_DURATION.getOrDefault(entityId, Collections.emptyList()));
hide.removeIf(e -> e.equals(effect));
if (heuristic && hideDuration) hide.add(effect);
if (hide.isEmpty()) {
HEURISTIC_HIDE_DURATION.remove(entityId);
} else {
HEURISTIC_HIDE_DURATION.put(entityId, hide);
}
}
/**
* Returns true if this entity already has the specified effect tracked.
*/
public static boolean has(int entityId, Holder<MobEffect> effect) {
List<MobEffectInstance> list = EFFECTS.get(entityId);
if (list == null) return false;
for (MobEffectInstance inst : list) {
if (inst.getEffect().equals(effect)) return true;
}
return false;
}
/**
* Returns true if effect is tracked as heuristic (not received via packet).
*/
public static boolean isHeuristic(int entityId, Holder<MobEffect> effect) {
List<Holder<MobEffect>> list = HEURISTIC.get(entityId);
if (list == null) return false;
for (Holder<MobEffect> e : list) {
if (e.equals(effect)) return true;
}
return false;
}
public static boolean shouldHideDuration(int entityId, Holder<MobEffect> effect) {
List<Holder<MobEffect>> list = HEURISTIC_HIDE_DURATION.get(entityId);
if (list == null) return false;
for (Holder<MobEffect> e : list) {
if (e.equals(effect)) return true;
}
return false;
}
public static void remove(int entityId, Holder<MobEffect> effect) {
List<MobEffectInstance> list = EFFECTS.get(entityId);
if (list == null) return;
DebugLog.info("Effect remove eid=%d (%s) %s",
entityId,
resolveName(entityId),
effect.unwrapKey().map(k -> k.identifier().toString()).orElse("unknown"));
list.removeIf(inst -> inst.getEffect().equals(effect));
if (list.isEmpty()) {
EFFECTS.remove(entityId);
} else {
EFFECTS.put(entityId, list);
}
List<Holder<MobEffect>> heur = HEURISTIC.get(entityId);
if (heur != null) {
heur.removeIf(e -> e.equals(effect));
if (heur.isEmpty()) HEURISTIC.remove(entityId);
}
List<Holder<MobEffect>> hide = HEURISTIC_HIDE_DURATION.get(entityId);
if (hide != null) {
hide.removeIf(e -> e.equals(effect));
if (hide.isEmpty()) HEURISTIC_HIDE_DURATION.remove(entityId);
}
}
public static List<MobEffectInstance> get(int entityId) {
List<MobEffectInstance> list = EFFECTS.get(entityId);
if (list == null) return Collections.emptyList();
return List.copyOf(list);
}
public static void clearEntity(int entityId) {
EFFECTS.remove(entityId);
HEURISTIC.remove(entityId);
HEURISTIC_HIDE_DURATION.remove(entityId);
}
public static void pruneMissing(java.util.Set<Integer> aliveIds) {
if (aliveIds == null || aliveIds.isEmpty()) {
EFFECTS.clear();
HEURISTIC.clear();
HEURISTIC_HIDE_DURATION.clear();
return;
}
EFFECTS.keySet().removeIf(id -> !aliveIds.contains(id));
HEURISTIC.keySet().removeIf(id -> !aliveIds.contains(id));
HEURISTIC_HIDE_DURATION.keySet().removeIf(id -> !aliveIds.contains(id));
}
/**
* Called every render frame to prune heuristic effects that stopped emitting particles.
*/
public static void tick(float partialTicks) {
StatusEffectHeuristics.pruneStale(System.currentTimeMillis());
}
public static void tickHeuristics() {
StatusEffectHeuristics.pruneStale(System.currentTimeMillis());
}
private static String resolveName(int entityId) {
var mc = Minecraft.getInstance();
if (mc == null || mc.level == null) return "unknown";
Entity e = mc.level.getEntity(entityId);
if (e == null) return "unknown";
if (e instanceof Player p) return p.getName().getString();
return e.getName().getString();
}
private static MobEffectInstance copyInstance(MobEffectInstance inst) {
return new MobEffectInstance(
inst.getEffect(),
inst.getDuration(),
inst.getAmplifier(),
inst.isAmbient(),
inst.isVisible(),
inst.showIcon()
);
}
private static boolean containsEffect(List<MobEffectInstance> list, Holder<MobEffect> effect) {
if (list == null || list.isEmpty()) return false;
for (MobEffectInstance inst : list) {
if (inst.getEffect().equals(effect)) return true;
}
return false;
}
private static MobEffectInstance findInstance(List<MobEffectInstance> list, Holder<MobEffect> effect) {
if (list == null || list.isEmpty()) return null;
for (MobEffectInstance inst : list) {
if (inst.getEffect().equals(effect)) return inst;
}
return null;
}
}

View 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.player.effect.tracker;
import net.minecraft.client.Minecraft;
import net.minecraft.core.Holder;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Player;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectInference;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectTracker;
/**
* Syncs status effects from client entities (no raw packets) and provides HUD-friendly subsets.
*/
public enum StatusEffectView {
;
private static final List<Holder<MobEffect>> HUD_EFFECTS = List.of(
MobEffects.STRENGTH,
MobEffects.FIRE_RESISTANCE,
MobEffects.SPEED,
MobEffects.SLOWNESS,
MobEffects.RESISTANCE,
MobEffects.ABSORPTION,
MobEffects.REGENERATION
);
public static void sync(Minecraft mc) {
if (mc == null || mc.level == null) return;
StatusEffectInference.tick(mc);
var players = mc.level.players();
if (players == null || players.isEmpty()) {
StatusEffectTracker.pruneMissing(java.util.Set.of());
StatusEffectTracker.tickHeuristics();
return;
}
HashSet<Integer> ids = new HashSet<>(players.size());
for (Player player : players) {
if (player == null) continue;
ids.add(player.getId());
StatusEffectTracker.syncEntity(player.getId(), player.getActiveEffects());
}
StatusEffectTracker.pruneMissing(ids);
StatusEffectTracker.tickHeuristics();
}
public static List<MobEffectInstance> collectHudEffects(Player player) {
if (player == null) return List.of();
List<MobEffectInstance> active = new ArrayList<>(StatusEffectAccess.all(player));
if (active.isEmpty()) return List.of();
List<MobEffectInstance> out = new ArrayList<>();
for (Holder<MobEffect> effect : HUD_EFFECTS) {
for (MobEffectInstance inst : active) {
if (inst.getEffect().equals(effect)) {
out.add(inst);
break;
}
}
}
return out;
}
}

View file

@ -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.player.health;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.scores.*;
import net.minecraft.world.scores.criteria.ObjectiveCriteria;
public enum PlayerHealthResolver {
;
private static final float TAB_OVERRIDE_DELTA = 2.0f;
public static HealthSnapshot resolve(LivingEntity entity) {
if (entity instanceof Player player) {
return resolve(player);
}
if (entity == null) {
return new HealthSnapshot(0.0f, 1.0f, 0.0f, false);
}
float health = Math.max(0.0f, entity.getHealth());
float absorption = Math.max(0.0f, entity.getAbsorptionAmount());
float maxHealth = Math.max(1.0f, entity.getMaxHealth() + absorption);
return new HealthSnapshot(health + absorption, maxHealth, absorption, false);
}
public static HealthSnapshot resolve(Player player) {
if (player == null) {
return new HealthSnapshot(0.0f, 1.0f, 0.0f, false);
}
float entityHealth = Math.max(0.0f, player.getHealth());
float entityAbsorption = Math.max(0.0f, player.getAbsorptionAmount());
float entityTotal = entityHealth + entityAbsorption;
float entityMax = Math.max(1.0f, player.getMaxHealth() + entityAbsorption);
float tabHealth = getTabListHealth(player);
if (!Float.isFinite(tabHealth) || !shouldUseTabHealth(player, entityHealth, entityTotal, tabHealth)) {
return new HealthSnapshot(entityTotal, entityMax, entityAbsorption, false);
}
float resolvedMax = Math.max(Math.max(1.0f, player.getMaxHealth()), tabHealth);
return new HealthSnapshot(tabHealth, resolvedMax, 0.0f, true);
}
public static float totalHealth(LivingEntity entity) {
return resolve(entity).totalHealth();
}
public static float maxHealth(LivingEntity entity) {
return resolve(entity).maxHealth();
}
private static boolean shouldUseTabHealth(Player player, float entityHealth, float entityTotal, float tabHealth) {
if (!player.isAlive()) {
return false;
}
if (entityHealth <= 0.0f && tabHealth > 0.0f) {
return true;
}
return Math.abs(entityTotal - tabHealth) >= TAB_OVERRIDE_DELTA;
}
private static float getTabListHealth(Player player) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null || player == null) {
return Float.NaN;
}
Scoreboard scoreboard = mc.level.getScoreboard();
if (scoreboard == null) {
return Float.NaN;
}
Objective objective = scoreboard.getDisplayObjective(DisplaySlot.LIST);
if (!isHealthObjective(objective)) {
return Float.NaN;
}
ReadOnlyScoreInfo score = scoreboard.getPlayerScoreInfo(ScoreHolder.fromGameProfile(player.getGameProfile()), objective);
if (score == null) {
String name = player.getGameProfile() != null ? player.getGameProfile().name() : null;
if (name == null || name.isBlank()) {
return Float.NaN;
}
score = scoreboard.getPlayerScoreInfo(ScoreHolder.forNameOnly(name), objective);
}
return score == null ? Float.NaN : Math.max(0.0f, score.value());
}
private static boolean isHealthObjective(Objective objective) {
if (objective == null) {
return false;
}
return objective.getRenderType() == ObjectiveCriteria.RenderType.HEARTS
|| objective.getCriteria() == ObjectiveCriteria.HEALTH;
}
public record HealthSnapshot(float totalHealth, float maxHealth, float absorption, boolean fromTabList) {
}
}

View file

@ -0,0 +1,168 @@
/*
* 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.player.health;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.resources.DefaultPlayerSkin;
import net.minecraft.core.ClientAsset;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.PlayerSkin;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
public enum PlayerSkinResolver {
;
private static final Map<UUID, Identifier> CACHE = new ConcurrentHashMap<>();
private static final Map<UUID, Long> LAST_RESOLVE_MS = new ConcurrentHashMap<>();
private static final Map<UUID, Supplier<PlayerSkin>> SUPPLIERS = new ConcurrentHashMap<>();
private static final long DEFAULT_RECHECK_MS = 1500L;
private static final long RESOLVED_RECHECK_MS = 30000L;
public static Identifier resolvePlayerSkin(AbstractClientPlayer player) {
if (player == null) return null;
return resolveProfileSkin(player.getGameProfile());
}
public static Identifier resolveProfileSkin(GameProfile profile) {
if (profile == null) return null;
UUID uuid = profile.id();
if (uuid == null) return null;
Identifier cached = CACHE.get(uuid);
long now = System.currentTimeMillis();
Long lastResolve = LAST_RESOLVE_MS.get(uuid);
if (cached != null && lastResolve != null) {
long interval = isRuntimeSkin(cached) ? RESOLVED_RECHECK_MS : DEFAULT_RECHECK_MS;
if (now - lastResolve < interval) {
return cached;
}
}
LAST_RESOLVE_MS.put(uuid, now);
Identifier resolved = null;
Identifier defaultId = null;
Identifier entryId = null;
Identifier providerId = null;
try {
PlayerSkin fallback = DefaultPlayerSkin.get(profile);
defaultId = resolveAssetId(fallback != null ? fallback.body() : null);
} catch (Throwable ignored) {
}
try {
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.getConnection() != null) {
PlayerInfo entry = mc.getConnection().getPlayerInfo(uuid);
if (entry != null) {
PlayerSkin textures = entry.getSkin();
entryId = resolveAssetId(textures != null ? textures.body() : null);
}
}
if (mc != null && mc.getSkinManager() != null) {
boolean requireSecure = !mc.isLocalPlayer(uuid);
Supplier<PlayerSkin> supplier = SUPPLIERS.computeIfAbsent(uuid, id ->
mc.getSkinManager().createLookup(profile, requireSecure));
if (supplier != null) {
PlayerSkin textures = supplier.get();
providerId = resolveAssetId(textures != null ? textures.body() : null);
}
}
} catch (Throwable ignored) {
}
if (entryId != null && (!entryId.equals(defaultId))) {
resolved = entryId;
} else if (providerId != null) {
resolved = providerId;
} else if (entryId != null) {
resolved = entryId;
} else {
resolved = defaultId;
}
if (resolved != null) {
if (cached == null || !cached.equals(resolved)) {
CACHE.put(uuid, resolved);
}
LAST_RESOLVE_MS.put(uuid, now);
return resolved;
}
if (cached != null) {
return cached;
}
Identifier steve = Identifier.fromNamespaceAndPath("minecraft", "textures/entity/player/wide/steve.png");
CACHE.put(uuid, steve);
LAST_RESOLVE_MS.put(uuid, now);
return steve;
}
private static boolean isRuntimeSkin(Identifier id) {
if (id == null) return false;
String path = id.getPath();
return path.startsWith("skins/") || path.startsWith("skin/");
}
public static Identifier resolveAssetId(ClientAsset asset) {
if (asset == null) return null;
Identifier id = asset.id();
if (id == null && asset instanceof ClientAsset.Texture tex) {
id = tex.texturePath();
}
return normalizeSkinId(id);
}
public static Identifier normalizeSkinId(Identifier id) {
if (id == null) return null;
String path = id.getPath();
// runtime skins (minecraft:skins/<hash>)
if (path.startsWith("skins/") || path.startsWith("skin/")
|| path.startsWith("capes/") || path.startsWith("cape/")) {
return id;
}
if (!path.startsWith("textures/")) {
path = "textures/" + path;
}
if (!path.endsWith(".png")) {
path = path + ".png";
}
return Identifier.fromNamespaceAndPath(id.getNamespace(), path);
}
/* optional utils */
public static void clear(UUID uuid) {
if (uuid != null) {
CACHE.remove(uuid);
LAST_RESOLVE_MS.remove(uuid);
SUPPLIERS.remove(uuid);
}
}
public static void clearAll() {
CACHE.clear();
LAST_RESOLVE_MS.clear();
SUPPLIERS.clear();
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.player.interaction;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.prediction.BlockStatePredictionHandler;
import net.minecraft.client.multiplayer.prediction.PredictiveAction;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.mixins.accessors.world.ClientLevelAccessor;
public enum InteractionUtil {
;
public static Vec3 getEyesPos(Entity entity) {
return entity.position().add(0, entity.getEyeHeight(entity.getPose()), 0);
}
public static float[] calculateAngle(Vec3 to) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) return new float[]{0f, 0f};
return calculateAngle(getEyesPos(mc.player), to);
}
public static float[] calculateAngle(Vec3 from, Vec3 to) {
double difX = to.x - from.x;
double difY = (to.y - from.y) * -1.0;
double difZ = to.z - from.z;
double dist = Mth.sqrt((float) (difX * difX + difZ * difZ));
float yaw = (float) Mth.wrapDegrees(Math.toDegrees(Math.atan2(difZ, difX)) - 90.0);
float pitch = (float) Mth.clamp(Mth.wrapDegrees(Math.toDegrees(Math.atan2(difY, dist))), -90f, 90f);
return new float[]{yaw, pitch};
}
public static void sendSequencedPacket(PredictiveAction packetCreator) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null || mc.getConnection() == null) return;
BlockStatePredictionHandler pending = ((ClientLevelAccessor) mc.level).lovisual$getPendingUpdateManager();
try (BlockStatePredictionHandler pendingUpdateManager = pending.startPredicting()) {
int id = pendingUpdateManager.currentSequence();
mc.getConnection().send(packetCreator.predict(id));
}
}
}

View file

@ -0,0 +1,143 @@
/*
* 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.player.interaction;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import dev.loki.lovisual.features.gui.hud.draggable.impl.stats.Ping;
/**
* Shared helpers for network stats (ping/TPS).
* Priority:
* - TPS: TAB -> client estimation -> 20
* - Ping: TAB -> player list latency
*/
public enum NetworkStatsUtil {
;
/* ================= CLIENT TPS ESTIMATION ================= */
private static final long SAMPLE_INTERVAL_MS = 500; // 500–1000 оптимально
private static final int MAX_TPS = 20;
private static final long TAB_TIMEOUT_MS = 3000;
private static long lastWorldTick = -1L;
private static long lastSampleTime = 0L;
private static long tickAccumulator = 0L;
/* ================= TAB OVERRIDES ================= */
private static float averagedTps = 20.0f;
private static volatile float tabTps = -1f;
private static volatile long lastTabTpsUpdate = 0L;
private static volatile int tabPing = -1;
private static volatile long lastTabPingUpdate = 0L;
/* ================= PUBLIC API ================= */
/**
* Returns player ping in ms; -1 if unavailable.
*/
public static int getPing(Minecraft mc) {
// 1) TAB ping
if (tabPing >= 0 && isFresh(lastTabPingUpdate)) {
return tabPing;
}
// 2) Player list latency
if (mc == null) return -1;
if (mc.hasSingleplayerServer()) return 0;
if (mc.player == null || mc.player.connection == null) return -1;
PlayerInfo entry =
mc.player.connection.getPlayerInfo(mc.player.getUUID());
return entry != null ? entry.getLatency() : -1;
}
/**
* Returns server TPS.
* Priority: TAB -> client estimate -> 20
*/
public static float getTps(Minecraft mc) {
float clientTps = updateClientEstimator(mc);
// 1) TAB TPS
if (tabTps > 0 && isFresh(lastTabTpsUpdate)) {
return tabTps;
}
// 2) Client estimation
return clientTps;
}
/**
* Returns TPS calculated only from client estimation (ignores TAB).
*/
public static float getClientTps(Minecraft mc) {
return updateClientEstimator(mc);
}
private static float updateClientEstimator(Minecraft mc) {
if (mc == null || mc.level == null) {
return averagedTps;
}
if (mc.hasSingleplayerServer()) {
averagedTps = 20.0f;
return averagedTps;
}
long now = System.currentTimeMillis();
long worldTick = mc.level.getGameTime();
if (lastWorldTick >= 0 && worldTick > lastWorldTick) {
tickAccumulator += (worldTick - lastWorldTick);
}
if (lastSampleTime == 0L) {
lastSampleTime = now;
lastWorldTick = worldTick;
return averagedTps;
}
long elapsed = now - lastSampleTime;
if (elapsed >= SAMPLE_INTERVAL_MS) {
float tps = (tickAccumulator * 1000.0f) / elapsed;
averagedTps = Math.min(MAX_TPS, Math.max(0f, tps));
tickAccumulator = 0L;
lastSampleTime = now;
}
lastWorldTick = worldTick;
return averagedTps;
}
/* ================= TAB UPDATE API ================= */
/**
* Called from TAB mixin
*/
public static void updateTabTps(float tps) {
tabTps = Math.min(MAX_TPS, Math.max(0f, tps));
lastTabTpsUpdate = System.currentTimeMillis();
}
/**
* Called from TAB mixin
*/
public static void updateTabPing(int ping) {
tabPing = Math.max(0, ping);
lastTabPingUpdate = System.currentTimeMillis();
}
/* ================= UTIL ================= */
private static boolean isFresh(long time) {
return (System.currentTimeMillis() - time) <= TAB_TIMEOUT_MS;
}
}

View file

@ -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.player.interaction;
import net.minecraft.world.entity.player.Player;
public enum ResetAttackCooldown {
;
public static void resetAttackCooldown(Player player) {
player.resetOnlyAttackStrengthTicker();
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.player.interaction;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.player.Abilities;
/**
* Helpers for detecting "server-granted" creative-like flight.
* <p>
* In vanilla, {@link Abilities#mayfly} is set by the server via abilities packets.
* This is true for Creative/Spectator, and can also be true on some servers that grant /fly
* in non-creative gamemodes.
*/
public enum ServerFlyUtils {
;
/**
* Returns true if the server currently allows the player to fly (Creative/Spectator or server-granted fly).
*/
public static boolean hasServerCreativeFly(LocalPlayer player) {
if (player == null) return false;
Abilities ab = player.getAbilities();
if (ab == null) return false;
return ab.mayfly || player.isSpectator();
}
/**
* Returns true if flight is allowed, but the player is not in Creative/Spectator.
* Useful if you specifically want to detect "server gave fly in survival-like modes".
*/
public static boolean hasServerGrantedFlyInSurvivalLike(LocalPlayer player) {
if (player == null) return false;
Abilities ab = player.getAbilities();
if (ab == null) return false;
if (ab.instabuild || player.isSpectator()) return false;
return ab.mayfly;
}
}

View file

@ -0,0 +1,145 @@
/*
* 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.player.inventory.firework;
import dev.loki.lovisual.mixins.accessors.inventory.PlayerInventoryAccessor;
import dev.loki.lovisual.mixins.accessors.misc.MultiPlayerGameModeAccessor;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.MultiPlayerGameMode;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.FireworkRocketEntity;
import net.minecraft.world.item.Items;
/**
* Shared firework-use path for elytra modules — hotbar-only, без InventorySwap.
*/
public final class FireworkUseController {
public static final FireworkUseController INSTANCE = new FireworkUseController();
public static final int DEFAULT_MANUAL_COOLDOWN_TICKS = 20;
private static final long MILLIS_PER_TICK = 50L;
private long lastUseMs;
private FireworkUseController() {
}
public boolean use(float yaw,
float pitch,
boolean allowInventory,
boolean restore,
boolean silent,
int cooldownTicks,
boolean syncCombat) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
MultiPlayerGameMode manager = mc.gameMode;
if (player == null || manager == null || mc.level == null) return false;
if (player.inventoryMenu == null) return false;
if (player.isUsingItem()) return false;
if (!canPassCooldown(cooldownTicks)) return false;
if (hasOwnedActiveFirework(player)) return false;
if (syncCombat && isKillAuraAttackWindowBusy()) return false;
boolean used;
if (player.getOffhandItem().is(Items.FIREWORK_ROCKET)) {
used = useWithTemporaryRotation(player, yaw, pitch, InteractionHand.OFF_HAND);
} else {
used = useMainHandRocket(player, yaw, pitch, restore);
}
if (used) {
lastUseMs = System.currentTimeMillis();
}
return used;
}
public boolean canPassCooldown(int cooldownTicks) {
int safeTicks = Math.max(0, cooldownTicks);
long delayMs = safeTicks * MILLIS_PER_TICK;
return System.currentTimeMillis() - lastUseMs >= delayMs;
}
public boolean isCooling(int cooldownTicks) {
return !canPassCooldown(cooldownTicks);
}
private boolean useMainHandRocket(LocalPlayer player, float yaw, float pitch, boolean restore) {
int slot = findFireworkInHotbar(player);
if (slot == -1) return false;
Minecraft mc = Minecraft.getInstance();
int original = ((PlayerInventoryAccessor) player.getInventory()).lovisual$getSelectedSlot();
if (slot != original) {
((PlayerInventoryAccessor) player.getInventory()).lovisual$setSelectedSlot(slot);
syncSlot(slot);
}
boolean ok = useWithTemporaryRotation(player, yaw, pitch, InteractionHand.MAIN_HAND);
if (restore && slot != original) {
((PlayerInventoryAccessor) player.getInventory()).lovisual$setSelectedSlot(original);
syncSlot(original);
}
return ok;
}
private int findFireworkInHotbar(LocalPlayer player) {
for (int i = 0; i < 9; i++) {
if (player.getInventory().getItem(i).is(Items.FIREWORK_ROCKET)) return i;
}
return -1;
}
private void syncSlot(int slot) {
Minecraft mc = Minecraft.getInstance();
if (mc.getConnection() == null) return;
if (mc.gameMode instanceof MultiPlayerGameModeAccessor accessor) {
accessor.lovisual$setLastSelectedSlot(slot);
}
mc.getConnection().send(new net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket(slot));
}
private boolean useWithTemporaryRotation(LocalPlayer player, float yaw, float pitch, InteractionHand hand) {
Minecraft mc = Minecraft.getInstance();
MultiPlayerGameMode manager = mc.gameMode;
if (manager == null) return false;
float oldYaw = player.getYRot();
float oldPitch = player.getXRot();
player.setYRot(yaw);
player.setXRot(pitch);
try {
InteractionResult result = manager.useItem(player, hand);
if (!result.consumesAction()) {
return false;
}
player.swing(hand);
return true;
} finally {
player.setYRot(oldYaw);
player.setXRot(oldPitch);
}
}
private boolean hasOwnedActiveFirework(LocalPlayer player) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return false;
for (Entity entity : mc.level.entitiesForRendering()) {
if (entity instanceof FireworkRocketEntity firework
&& firework.isAlive()
&& firework.getOwner() == player) {
return true;
}
}
return false;
}
private boolean isKillAuraAttackWindowBusy() {
return false;
}
}

View file

@ -0,0 +1,277 @@
/*
* 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.player.movement;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.AABB;
import dev.loki.lovisual.events.impl.network.EventSync;
import dev.loki.lovisual.events.impl.movement.MovementInputEvent;
import java.util.IdentityHashMap;
import java.util.Map;
public final class ElytraRecastUtil {
public static final ElytraRecastUtil INSTANCE = new ElytraRecastUtil();
private static final int NORMAL_COOLDOWN_TICKS = 5;
private static final int GRIM_COOLDOWN_TICKS = 3;
private static final double VANILLA_JUMP_HEADROOM = 0.42;
private final Map<Object, RecastState> states = new IdentityHashMap<>();
private ElytraRecastUtil() {
}
private static Minecraft client() {
return Minecraft.getInstance();
}
private static boolean isElytraUsable(ItemStack stack) {
if (stack == null || stack.isEmpty() || !stack.is(Items.ELYTRA)) return false;
if (!stack.isDamageableItem()) return true;
return (stack.getMaxDamage() - stack.getDamageValue()) > 1;
}
public void request(
Object owner,
LocalPlayer player,
Mode mode,
boolean allowBroken,
boolean requireJumpHeld,
boolean autoWalk,
int delayTicks
) {
if (owner == null) return;
RecastState state = state(owner);
state.mode = mode == null ? Mode.NORMAL : mode;
state.allowBroken = allowBroken;
state.requireJumpHeld = requireJumpHeld;
state.autoWalk = autoWalk;
if (state.phase != Phase.IDLE) {
return;
}
if (!canAttempt(player, state)) {
reset(owner);
return;
}
if (state.autoWalk) {
client().options.keyUp.setDown(true);
state.forcedForward = true;
}
state.delayTicks = Math.max(0, delayTicks);
if (state.delayTicks > 0) {
state.phase = Phase.WAIT_DELAY;
} else {
beginStartPhase(state, player);
}
}
public void tick(Object owner, LocalPlayer player) {
RecastState state = states.get(owner);
if (state == null) return;
if (state.phase == Phase.IDLE) {
releaseForcedKeys(state);
return;
}
if (state.phase == Phase.COOLDOWN) {
if (state.cooldownTicks > 0) {
state.cooldownTicks--;
}
if (state.cooldownTicks <= 0 || player == null || player.isFallFlying()) {
reset(owner);
}
return;
}
if (!canAttempt(player, state)) {
if (player != null && player.isFallFlying()) {
state.phase = Phase.COOLDOWN;
state.cooldownTicks = state.mode == Mode.GRIM ? GRIM_COOLDOWN_TICKS : NORMAL_COOLDOWN_TICKS;
return;
}
reset(owner);
return;
}
if (state.phase == Phase.WAIT_DELAY) {
if (state.delayTicks > 0) {
state.delayTicks--;
}
if (state.delayTicks <= 0) {
beginStartPhase(state, player);
}
return;
}
if (state.phase == Phase.VANILLA_START_JUMP && state.jumpTicks <= 0) {
state.phase = Phase.COOLDOWN;
state.cooldownTicks = state.mode == Mode.GRIM ? GRIM_COOLDOWN_TICKS : NORMAL_COOLDOWN_TICKS;
}
}
public boolean handleMovementInput(Object owner, MovementInputEvent event) {
RecastState state = states.get(owner);
if (state == null) return false;
if (state.phase == Phase.GRIM_RELEASE) {
event.setJump(false);
state.phase = Phase.VANILLA_START_JUMP;
state.jumpTicks = 1;
return true;
}
if (state.phase == Phase.VANILLA_START_JUMP) {
LocalPlayer player = client().player;
if (!canAttempt(player, state)) {
reset(owner);
return false;
}
event.setJump(true);
if (state.jumpTicks > 0) {
state.jumpTicks--;
}
if (state.jumpTicks <= 0) {
state.phase = Phase.COOLDOWN;
state.cooldownTicks = state.mode == Mode.GRIM ? GRIM_COOLDOWN_TICKS : NORMAL_COOLDOWN_TICKS;
}
return true;
}
return false;
}
public boolean handleSync(Object owner, EventSync event) {
return false;
}
public boolean isActive(Object owner) {
RecastState state = states.get(owner);
return state != null && state.phase != Phase.IDLE;
}
public void reset(Object owner) {
RecastState state = states.remove(owner);
if (state != null) {
releaseForcedKeys(state);
}
}
private RecastState state(Object owner) {
return states.computeIfAbsent(owner, ignored -> new RecastState());
}
private void beginStartPhase(RecastState state, LocalPlayer player) {
state.jumpTicks = 0;
if (state.mode == Mode.GRIM || isJumpInputActive(player)) {
state.phase = Phase.GRIM_RELEASE;
} else {
state.phase = Phase.VANILLA_START_JUMP;
state.jumpTicks = 1;
}
}
private boolean canAttempt(LocalPlayer player, RecastState state) {
if (player == null || client().options == null) return false;
if (player.isFallFlying() || player.onGround()) return false;
if (player.getDeltaMovement().y > -0.01 && player.fallDistance <= 0.0f) return false;
if (state.requireJumpHeld && !client().options.keyJump.isDown()) return false;
if (player.getAbilities().flying) return false;
if (player.isPassenger()) return false;
if (player.onClimbable()) return false;
if (player.isInWater() || player.isInLava()) return false;
if (player.hasEffect(MobEffects.LEVITATION)) return false;
if (!hasVanillaGlideStartSpace(player)) return false;
ItemStack chest = player.getItemBySlot(EquipmentSlot.CHEST);
return chest.is(Items.ELYTRA) && (isElytraUsable(chest) || state.allowBroken);
}
private boolean hasVanillaGlideStartSpace(LocalPlayer player) {
if (player == null || player.level() == null) return false;
AABB glidingBox = player.getLocalBoundsForPose(Pose.FALL_FLYING).deflate(1.0E-7);
if (!player.level().noCollision(player, glidingBox)) {
return false;
}
AABB jumpHeadroomBox = player.getBoundingBox()
.expandTowards(0.0, VANILLA_JUMP_HEADROOM, 0.0)
.deflate(1.0E-7);
return player.level().noCollision(player, jumpHeadroomBox);
}
private boolean isJumpInputActive(LocalPlayer player) {
if (player != null && player.input != null && player.input.keyPresses != null && player.input.keyPresses.jump()) {
return true;
}
return client().options != null && client().options.keyJump.isDown();
}
private void releaseForcedKeys(RecastState state) {
if (!state.forcedForward || client().options == null) return;
state.forcedForward = false;
client().options.keyUp.setDown(false);
KeyMapping.setAll();
}
public enum Mode {
NORMAL("normal"),
GRIM("grim");
private final String id;
Mode(String id) {
this.id = id;
}
public static Mode from(String value) {
return "grim".equalsIgnoreCase(String.valueOf(value)) ? GRIM : NORMAL;
}
public String id() {
return id;
}
}
private enum Phase {
IDLE,
WAIT_DELAY,
GRIM_RELEASE,
VANILLA_START_JUMP,
COOLDOWN
}
private static final class RecastState {
private Mode mode = Mode.NORMAL;
private boolean allowBroken;
private boolean requireJumpHeld;
private boolean autoWalk;
private boolean forcedForward;
private int delayTicks;
private int cooldownTicks;
private int jumpTicks;
private Phase phase = Phase.IDLE;
}
}

View file

@ -0,0 +1,59 @@
/*
* 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.player.movement;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
public enum EnvironmentMovementController {
;
private static final float DEFAULT_SLIPPERINESS = 0.6F;
private static final float DEFAULT_VELOCITY_MULTIPLIER = 1.0F;
private static final float STICKY_SLIPPERINESS = 0.8F;
private static final float STICKY_VELOCITY_MULTIPLIER = 0.4F;
public static Float getSlipperinessOverride(Block block) {
// NoStun module removed; no slipperiness override remains.
return null;
}
public static Float getVelocityMultiplierOverride(Block block) {
// NoStun module removed; no velocity multiplier override remains.
return null;
}
public static boolean shouldPreserveHoneySlide(Entity entity) {
if (isNoStunHoneyEnabled()) {
return true;
}
return false;
}
private static boolean isNoStunHoneyEnabled() {
// NoStun module removed; never preserve honey slide.
return false;
}
private static float vanillaSlipperiness(Block block) {
if (block == Blocks.HONEY_BLOCK || block == Blocks.SLIME_BLOCK) {
return STICKY_SLIPPERINESS;
}
return DEFAULT_SLIPPERINESS;
}
private static float vanillaVelocityMultiplier(Block block) {
if (block == Blocks.HONEY_BLOCK || block == Blocks.SOUL_SAND) {
return STICKY_VELOCITY_MULTIPLIER;
}
return DEFAULT_VELOCITY_MULTIPLIER;
}
}

View file

@ -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.player.movement;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
public enum MovementUtil {
;
public static boolean isMoving() {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc != null ? mc.player : null;
return player != null
&& player.input != null
&& (player.input.getMoveVector().x != 0.0F || player.input.getMoveVector().y != 0.0F);
}
public static float getMovementDirectionYaw(LocalPlayer player, float fallbackYaw) {
if (player == null || player.input == null) return fallbackYaw;
var move = player.input.getMoveVector();
float forward = move.y;
float strafe = move.x;
if (forward == 0.0f && strafe == 0.0f) return fallbackYaw;
float yaw = resolveControlYaw(player, fallbackYaw);
double sin = Math.sin(Math.toRadians(yaw + 90.0f));
double cos = Math.cos(Math.toRadians(yaw + 90.0f));
double x = forward * cos + strafe * sin;
double z = forward * sin - strafe * cos;
if (Math.abs(x) < 1.0E-6 && Math.abs(z) < 1.0E-6) return fallbackYaw;
return (float) Math.toDegrees(Math.atan2(z, x)) - 90.0f;
}
public static double[] forward(double speed) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc != null ? mc.player : null;
if (player == null || player.input == null) return new double[]{0.0, 0.0};
var move = player.input.getMoveVector();
float forward = move.y;
float strafe = move.x;
float yaw = resolveControlYaw(player, player.getYRot());
if (forward != 0.0f) {
if (strafe > 0.0f) {
yaw += (forward > 0.0f ? -45f : 45f);
} else if (strafe < 0.0f) {
yaw += (forward > 0.0f ? 45f : -45f);
}
strafe = 0.0f;
if (forward > 0.0f) forward = 1.0f;
else if (forward < 0.0f) forward = -1.0f;
}
double sin = Math.sin(Math.toRadians(yaw + 90.0f));
double cos = Math.cos(Math.toRadians(yaw + 90.0f));
double x = forward * speed * cos + strafe * speed * sin;
double z = forward * speed * sin - strafe * speed * cos;
return new double[]{x, z};
}
public static double[] forwardWithoutStrafe(double speed) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc != null ? mc.player : null;
if (player == null) return new double[]{0.0, 0.0};
float yaw = resolveControlYaw(player, player.getYRot());
double x = speed * Math.cos(Math.toRadians(yaw + 90.0f));
double z = speed * Math.sin(Math.toRadians(yaw + 90.0f));
return new double[]{x, z};
}
private static float resolveControlYaw(LocalPlayer player, float fallbackYaw) {
Minecraft mc = Minecraft.getInstance();
if (mc != null && player != null && player == mc.player) {
var rotation = RotationManager.INSTANCE.getMovementRotation();
if (rotation != null) {
return rotation.yaw();
}
}
return fallbackYaw;
}
public static Vec3 withStrafe(Vec3 currentVelocity, Vec3 movementInput, float yaw, double speed, double strength) {
if (currentVelocity == null) currentVelocity = Vec3.ZERO;
if (movementInput == null || movementInput.lengthSqr() < 1.0E-7) {
return new Vec3(0.0, currentVelocity.y, 0.0);
}
if (strength < 0.0) strength = 0.0;
if (strength > 1.0) strength = 1.0;
Vec3 input = movementInput;
double lenSq = input.lengthSqr();
if (lenSq > 1.0) {
input = input.normalize();
}
double prevX = currentVelocity.x * (1.0 - strength);
double prevZ = currentVelocity.z * (1.0 - strength);
double useSpeed = speed * strength;
double rad = Math.toRadians(yaw);
double sin = Math.sin(rad);
double cos = Math.cos(rad);
double x = (input.x * cos - input.z * sin) * useSpeed + prevX;
double z = (input.z * cos + input.x * sin) * useSpeed + prevZ;
return new Vec3(x, currentVelocity.y, z);
}
/**
* Strafe helper that uses a world-space direction vector.
*/
public static Vec3 withStrafeDirection(Vec3 currentVelocity, Vec3 direction, double speed, double strength) {
if (currentVelocity == null) currentVelocity = Vec3.ZERO;
if (direction == null) {
return new Vec3(0.0, currentVelocity.y, 0.0);
}
double dirLen = Math.hypot(direction.x, direction.z);
if (dirLen < 1.0E-7) {
return new Vec3(0.0, currentVelocity.y, 0.0);
}
if (strength < 0.0) strength = 0.0;
if (strength > 1.0) strength = 1.0;
double dirX = direction.x / dirLen;
double dirZ = direction.z / dirLen;
double prevX = currentVelocity.x * (1.0 - strength);
double prevZ = currentVelocity.z * (1.0 - strength);
double useSpeed = speed * strength;
double x = dirX * useSpeed + prevX;
double z = dirZ * useSpeed + prevZ;
return new Vec3(x, currentVelocity.y, z);
}
}

View file

@ -0,0 +1,31 @@
/*
* 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.player.movement;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.Vec3;
/**
* Speed helpers for BPS calculations.
* кулькулятор
*/
public enum PlayerSpeedUtil {
;
/**
* Returns absolute speed in blocks per second for the provided entity (XYZ magnitude).
*/
public static float getBps(Entity entity) {
if (entity == null) return 0f;
Entity base = entity.getVehicle() != null ? entity.getVehicle() : entity;
Vec3 vel = base.getDeltaMovement();
double vy = base.onGround() ? 0.0 : vel.y; // ignore tiny gravity jitter when grounded
double speed = Math.sqrt(vel.x * vel.x + vy * vy + vel.z * vel.z);
return (float) (speed * 20.0);
}
}

View file

@ -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.player.simulation;
import net.minecraft.world.entity.player.Player;
import dev.loki.lovisual.util.player.simulation.PlayerMovementSimulation;
import dev.loki.lovisual.util.player.simulation.SimulatedInput;
public final class LocalPlayerSimulation extends PlayerMovementSimulation {
LocalPlayerSimulation(Player player, SimulatedInput input) {
super(player, input);
}
}

View file

@ -0,0 +1,157 @@
/*
* 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.player.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess;
import dev.loki.lovisual.util.player.simulation.remote.RemotePlayerObservation;
public abstract class PlayerMovementSimulation {
static final double STEP_HEIGHT = 0.5;
final Player player;
final SimulatedInput input;
final float yaw;
final float pitch;
final boolean sprinting;
Vec3 pos;
Vec3 velocity;
AABB boundingBox;
boolean onGround;
boolean jumping;
boolean gliding;
double fallDistance;
boolean horizontalCollision;
boolean clipLedged;
boolean touchingWater;
boolean submergedInWater;
boolean swimming;
double waterHeight;
double lavaHeight;
int jumpingCooldown;
protected PlayerMovementSimulation(Player player, SimulatedInput input) {
this.player = player;
this.pos = player.position();
this.velocity = this.pos.subtract(player.xo, player.yo, player.zo);
this.boundingBox = player.getBoundingBox();
this.yaw = player.getYRot();
this.pitch = player.getXRot();
this.sprinting = player.isSprinting();
this.onGround = player.onGround();
this.fallDistance = player.fallDistance;
this.jumping = !player.onGround();
this.gliding = player.isFallFlying();
this.input = input;
}
void tick() {
clipLedged = false;
input.update();
FluidStateHelper.updateWaterState(this);
FluidStateHelper.updateSubmergedInWaterState(this);
FluidStateHelper.updateSwimming(this);
SneakAndClimbHelper.applyBubbleColumnEffects(this);
if (jumpingCooldown > 0) {
jumpingCooldown--;
}
jumping = input.jump;
double vx = Math.abs(velocity.x) < 0.003 ? 0.0 : velocity.x;
double vy = Math.abs(velocity.y) < 0.003 ? 0.0 : velocity.y;
double vz = Math.abs(velocity.z) < 0.003 ? 0.0 : velocity.z;
velocity = new Vec3(vx, vy, vz);
if (jumping && FluidPhysicsHelper.shouldSwimInFluids(this)) {
double fluidHeight = FluidPhysicsHelper.isInLava(this) ? lavaHeight : waterHeight;
boolean inWater = touchingWater && fluidHeight > 0.0;
double swimHeight = FluidPhysicsHelper.getSwimHeight(this);
if (!inWater || onGround && !(fluidHeight > swimHeight)) {
if (!FluidPhysicsHelper.isInLava(this) || onGround && !(fluidHeight > swimHeight)) {
if ((onGround || inWater && fluidHeight <= swimHeight) && jumpingCooldown == 0) {
FluidPhysicsHelper.jump(this);
jumpingCooldown = 10;
}
} else {
FluidPhysicsHelper.swimUpward(this);
}
} else {
FluidPhysicsHelper.swimUpward(this);
}
} else {
jumpingCooldown = 0;
}
if (StatusEffectAccess.has(player, MobEffects.SLOW_FALLING) || StatusEffectAccess.has(player, MobEffects.LEVITATION)) {
onGround = false;
}
TravelHelper.travel(this, new Vec3(input.movementSideways * 0.98f, 0.0, input.movementForward * 0.98f));
}
protected boolean shouldUseRemoteFlightPhysics() {
return false;
}
protected boolean shouldHoldVerticalMotion() {
return false;
}
protected RemotePlayerObservation.MotionKind motionKind() {
return RemotePlayerObservation.MotionKind.NORMAL;
}
BlockPos getVelocityAffectingPos() {
return BlockPos.containing(pos.x, boundingBox.minY - 0.5000001, pos.z);
}
protected void setSimulatedVelocity(Vec3 velocity) {
this.velocity = velocity;
}
protected void setSimulatedFallDistance(double fallDistance) {
this.fallDistance = fallDistance;
}
protected void setSimulatedOnGround(boolean onGround) {
this.onGround = onGround;
}
protected void setSimulatedGliding(boolean gliding) {
this.gliding = gliding;
}
Vec3 position() {
return pos;
}
double simulatedFallDistance() {
return fallDistance;
}
Vec3 simulatedVelocity() {
return velocity;
}
boolean simulatedOnGround() {
return onGround;
}
boolean simulatedClipLedged() {
return clipLedged;
}
}

View file

@ -0,0 +1,152 @@
/*
* 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.player.simulation;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Player;
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.movement.MovementInputEvent;
import java.util.*;
import dev.loki.lovisual.util.player.simulation.LocalPlayerSimulation;
import dev.loki.lovisual.util.player.simulation.PlayerMovementSimulation;
import dev.loki.lovisual.util.player.simulation.remote.RemotePlayerObservation;
import dev.loki.lovisual.util.player.simulation.remote.RemotePlayerSimulation;
import dev.loki.lovisual.util.player.simulation.SimulatedInput;
public final class PlayerSimulationCache {
public static final PlayerSimulationCache INSTANCE = new PlayerSimulationCache();
private final Map<Player, SimulatedPlayerCache> otherPlayerCache = new WeakHashMap<>();
private final Map<Player, RemotePlayerObservation> remoteObservations = new WeakHashMap<>();
private SimulatedPlayerCache localPlayerCache;
private PlayerSimulationCache() {
}
public static SimulatedPlayerCache getSimulationForOtherPlayers(Player player) {
RemotePlayerObservation observation = INSTANCE.remoteObservations.computeIfAbsent(player, RemotePlayerObservation::new);
observation.update(player);
return INSTANCE.otherPlayerCache.computeIfAbsent(player,
p -> new SimulatedPlayerCache(
new RemotePlayerSimulation(p, SimulatedInput.guessInput(p), observation)
));
}
public static SimulatedPlayerCache getSimulationForLocalPlayer() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) {
return null;
}
SimulatedPlayerCache cached = INSTANCE.localPlayerCache;
if (cached != null) {
return cached;
}
cached = new SimulatedPlayerCache(
new LocalPlayerSimulation(
mc.player,
SimulatedInput.fromPlayer(mc.player)
)
);
INSTANCE.localPlayerCache = cached;
return cached;
}
@EventHandler
private void onGameTick(GameTickEvent event) {
otherPlayerCache.clear();
localPlayerCache = null;
pruneRemoteObservations();
}
@EventHandler
private void onMovementInput(MovementInputEvent event) {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.player == null) {
localPlayerCache = null;
return;
}
localPlayerCache = new SimulatedPlayerCache(
new LocalPlayerSimulation(
mc.player,
SimulatedInput.fromMovementEvent(mc.player, event)
)
);
}
private void pruneRemoteObservations() {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null) {
remoteObservations.clear();
return;
}
Iterator<Player> iterator = remoteObservations.keySet().iterator();
while (iterator.hasNext()) {
Player player = iterator.next();
if (player == null || player.isRemoved() || player.level() != mc.level) {
iterator.remove();
}
}
}
public static final class SimulatedPlayerCache {
private final PlayerMovementSimulation simulator;
private final List<SimulatedPlayerSnapshot> snapshots = new ArrayList<>();
private int simulatedTicks;
private SimulatedPlayerCache(PlayerMovementSimulation simulator) {
this.simulator = simulator;
this.snapshots.add(new SimulatedPlayerSnapshot(simulator));
}
public SimulatedPlayerSnapshot getSnapshotAt(int ticks) {
simulateUntil(ticks);
return snapshots.get(ticks);
}
public boolean isRemoteFlightLike() {
return simulator.motionKind() == RemotePlayerObservation.MotionKind.SERVER_FLIGHT
|| simulator.motionKind() == RemotePlayerObservation.MotionKind.SURVIVAL_FLIGHT
|| simulator.motionKind() == RemotePlayerObservation.MotionKind.HOVER
|| simulator.motionKind() == RemotePlayerObservation.MotionKind.FROZEN_AIR;
}
public boolean isFrozenInAir() {
return simulator.motionKind() == RemotePlayerObservation.MotionKind.FROZEN_AIR;
}
private void simulateUntil(int ticks) {
while (simulatedTicks < ticks) {
simulator.tick();
snapshots.add(new SimulatedPlayerSnapshot(simulator));
simulatedTicks++;
}
}
}
public record SimulatedPlayerSnapshot(Vec3 pos, double fallDistance, Vec3 velocity, boolean onGround,
boolean clipLedged) {
private SimulatedPlayerSnapshot(PlayerMovementSimulation simulator) {
this(
simulator.position(),
simulator.simulatedFallDistance(),
simulator.simulatedVelocity(),
simulator.simulatedOnGround(),
simulator.simulatedClipLedged()
);
}
}
}

View file

@ -0,0 +1,132 @@
/*
* 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.player.simulation;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.movement.MovementInputEvent;
import dev.loki.lovisual.events.impl.movement.PlayerSafeWalkEvent;
public final class SimulatedInput {
private static final double MAX_WALKING_SPEED = 0.121;
final boolean forward;
final boolean backward;
final boolean left;
final boolean right;
final boolean jump;
final boolean sneak;
final boolean sprint;
boolean ignoreClippingAtLedge;
boolean forceSafeWalk;
float movementForward;
float movementSideways;
private SimulatedInput(boolean forward, boolean backward, boolean left, boolean right, boolean jump, boolean sneak, boolean sprint) {
this.forward = forward;
this.backward = backward;
this.left = left;
this.right = right;
this.jump = jump;
this.sneak = sneak;
this.sprint = sprint;
}
static SimulatedInput fromPlayer(Player player) {
if (player instanceof LocalPlayer clientPlayer && clientPlayer.input != null) {
SimulatedInput input = new SimulatedInput(
clientPlayer.input.keyPresses.forward(),
clientPlayer.input.keyPresses.backward(),
clientPlayer.input.keyPresses.left(),
clientPlayer.input.keyPresses.right(),
clientPlayer.input.keyPresses.jump(),
clientPlayer.input.keyPresses.shift(),
clientPlayer.input.keyPresses.sprint()
);
input.forceSafeWalk = shouldForceSafeWalk();
return input;
}
return guessInput(player);
}
static SimulatedInput fromMovementEvent(Player player, MovementInputEvent event) {
SimulatedInput input = new SimulatedInput(
event.isForward(),
event.isBackward(),
event.isLeft(),
event.isRight(),
event.isJump(),
event.isSneak(),
event.isSprint()
);
input.forceSafeWalk = shouldForceSafeWalk();
input.ignoreClippingAtLedge = false;
return input;
}
static SimulatedInput guessInput(Player player) {
Vec3 velocity = player.position().subtract(player.xo, player.yo, player.zo);
double horizontalSpeedSq = velocity.x * velocity.x + velocity.z * velocity.z;
boolean sprinting = horizontalSpeedSq >= MAX_WALKING_SPEED * MAX_WALKING_SPEED;
boolean jump = !player.onGround();
boolean sneak = player.isShiftKeyDown();
if (horizontalSpeedSq <= 0.05 * 0.05) {
return new SimulatedInput(false, false, false, false, jump, sneak, sprinting);
}
double yawRad = Math.toRadians(player.getYRot());
double sideways = velocity.x * Math.cos(yawRad) + velocity.z * Math.sin(yawRad);
double forward = velocity.z * Math.cos(yawRad) - velocity.x * Math.sin(yawRad);
boolean forwardKey = forward > 0.05;
boolean backwardKey = forward < -0.05;
boolean leftKey = sideways > 0.05;
boolean rightKey = sideways < -0.05;
SimulatedInput input = new SimulatedInput(forwardKey, backwardKey, leftKey, rightKey, jump, sneak, sprinting);
if (sprinting) {
input.update();
}
return input;
}
private static boolean shouldForceSafeWalk() {
if (!Events.BUS.hasListeners(PlayerSafeWalkEvent.class)) {
return false;
}
PlayerSafeWalkEvent event = new PlayerSafeWalkEvent();
Events.BUS.post(event);
return event.isSafeWalk();
}
void update() {
if (forward != backward) {
movementForward = forward ? 1.0f : -1.0f;
} else {
movementForward = 0.0f;
}
if (left == right) {
movementSideways = 0.0f;
} else {
movementSideways = left ? 1.0f : -1.0f;
}
if (sneak) {
movementForward *= 0.3f;
movementSideways *= 0.3f;
}
}
}

View file

@ -0,0 +1,151 @@
/*
* 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.player.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
final class FluidPhysicsHelper {
private FluidPhysicsHelper() {
}
static void jump(PlayerMovementSimulation sim) {
double jumpVelocity = sim.player.getAttributeValue(Attributes.JUMP_STRENGTH) * getJumpVelocityMultiplier(sim)
+ sim.player.getJumpBoostPower();
if (jumpVelocity <= 1.0E-5) {
return;
}
sim.velocity = new Vec3(sim.velocity.x, Math.max(jumpVelocity, sim.velocity.y), sim.velocity.z);
if (sim.sprinting) {
float yawRadians = sim.yaw * Mth.DEG_TO_RAD;
sim.velocity = sim.velocity.add(-Mth.sin(yawRadians) * 0.2f, 0.0, Mth.cos(yawRadians) * 0.2f);
}
}
static double getJumpVelocityMultiplier(PlayerMovementSimulation sim) {
BlockPos feetPos = BlockPos.containing(sim.pos);
float feet = sim.player.level().getBlockState(feetPos).getBlock().getJumpFactor();
float floor = sim.player.level().getBlockState(sim.getVelocityAffectingPos()).getBlock().getJumpFactor();
return feet == 1.0f ? floor : feet;
}
static void travelInFluid(PlayerMovementSimulation sim, Vec3 movementInput) {
boolean falling = sim.velocity.y <= 0.0;
double y = sim.pos.y;
double gravity = TravelHelper.getEffectiveGravity(sim);
if (sim.touchingWater) {
travelInWater(sim, movementInput, gravity, falling, y);
} else {
travelInLava(sim, movementInput, gravity, falling, y);
}
}
static void travelInWater(PlayerMovementSimulation sim, Vec3 movementInput, double gravity, boolean falling, double y) {
float movementMultiplier = sim.sprinting ? 0.9f : 0.8f;
float acceleration = 0.02f;
float waterEfficiency = (float) sim.player.getAttributeValue(Attributes.WATER_MOVEMENT_EFFICIENCY);
if (!sim.onGround) {
waterEfficiency *= 0.5f;
}
if (waterEfficiency > 0.0f) {
movementMultiplier += (0.54600006f - movementMultiplier) * waterEfficiency;
acceleration += (sim.player.getSpeed() - acceleration) * waterEfficiency;
}
if (dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess.has(sim.player, net.minecraft.world.effect.MobEffects.DOLPHINS_GRACE)) {
movementMultiplier = 0.96f;
}
TravelHelper.updateVelocity(sim, acceleration, movementInput);
SneakAndClimbHelper.move(sim, sim.velocity);
Vec3 movedVelocity = sim.velocity;
if (sim.horizontalCollision && SneakAndClimbHelper.isClimbing(sim)) {
movedVelocity = new Vec3(movedVelocity.x, 0.2, movedVelocity.z);
}
movedVelocity = movedVelocity.multiply(movementMultiplier, 0.8f, movementMultiplier);
sim.velocity = applyFluidMovingSpeed(sim, gravity, falling, movedVelocity);
resetVerticalVelocityInFluid(sim, y);
}
static void travelInLava(PlayerMovementSimulation sim, Vec3 movementInput, double gravity, boolean falling, double y) {
TravelHelper.updateVelocity(sim, 0.02f, movementInput);
SneakAndClimbHelper.move(sim, sim.velocity);
if (sim.lavaHeight <= getSwimHeight(sim)) {
sim.velocity = sim.velocity.multiply(0.5, 0.8f, 0.5);
sim.velocity = applyFluidMovingSpeed(sim, gravity, falling, sim.velocity);
} else {
sim.velocity = sim.velocity.scale(0.5);
}
if (gravity != 0.0) {
sim.velocity = sim.velocity.add(0.0, -gravity / 4.0, 0.0);
}
resetVerticalVelocityInFluid(sim, y);
}
static Vec3 applyFluidMovingSpeed(PlayerMovementSimulation sim, double gravity, boolean falling, Vec3 motion) {
if (gravity != 0.0 && !sim.sprinting) {
double y = falling && Math.abs(motion.y - 0.005) >= 0.003 && Math.abs(motion.y - gravity / 16.0) < 0.003
? -0.003
: motion.y - gravity / 16.0;
return new Vec3(motion.x, y, motion.z);
}
return motion;
}
static void resetVerticalVelocityInFluid(PlayerMovementSimulation sim, double y) {
if (sim.horizontalCollision && doesNotCollide(sim, sim.velocity.x, sim.velocity.y + 0.6f - sim.pos.y + y, sim.velocity.z)) {
sim.velocity = new Vec3(sim.velocity.x, 0.3f, sim.velocity.z);
}
}
static boolean doesNotCollide(PlayerMovementSimulation sim, double offsetX, double offsetY, double offsetZ) {
net.minecraft.world.phys.AABB moved = sim.boundingBox.move(offsetX, offsetY, offsetZ);
return sim.player.level().noCollision(sim.player, moved) && !sim.player.level().containsAnyLiquid(moved);
}
static boolean shouldSwimInFluids(PlayerMovementSimulation sim) {
return !sim.player.getAbilities().flying;
}
static boolean isInLava(PlayerMovementSimulation sim) {
return sim.lavaHeight > 0.0;
}
static double getSwimHeight(PlayerMovementSimulation sim) {
return sim.player.getEyeHeight(sim.player.getPose()) < 0.4f ? 0.0 : 0.4;
}
static void swimUpward(PlayerMovementSimulation sim) {
sim.velocity = sim.velocity.add(0.0, 0.04f, 0.0);
}
static Vec3 applyWebSpeed(PlayerMovementSimulation sim, Vec3 motion) {
BlockState state = sim.player.level().getBlockState(BlockPos.containing(sim.pos));
if (!state.is(Blocks.COBWEB)) {
return motion;
}
if (dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess.has(sim.player, net.minecraft.world.effect.MobEffects.WEAVING)) {
return motion.multiply(0.5, 0.25, 0.5);
}
return motion.multiply(0.25, 0.05, 0.25);
}
}

View file

@ -0,0 +1,182 @@
/*
* 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.player.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.mixins.accessors.entity.EntityInvoker;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess;
final class TravelHelper {
private TravelHelper() {
}
static void travel(PlayerMovementSimulation sim, Vec3 movementInput) {
if (sim.swimming && !sim.player.isPassenger()) {
double lookY = getRotationVector(sim).y;
double climb = lookY < -0.2 ? 0.085 : 0.06;
BlockPos fluidPos = BlockPos.containing(sim.pos.x, sim.pos.y + 0.9, sim.pos.z);
if (lookY <= 0.0 || sim.jumping || !sim.player.level().getFluidState(fluidPos).isEmpty()) {
sim.velocity = sim.velocity.add(0.0, (lookY - sim.velocity.y) * climb, 0.0);
}
}
if ((sim.touchingWater || FluidPhysicsHelper.isInLava(sim)) && FluidPhysicsHelper.shouldSwimInFluids(sim)) {
FluidPhysicsHelper.travelInFluid(sim, movementInput);
return;
}
if (sim.shouldUseRemoteFlightPhysics()) {
travelRemoteFlight(sim, movementInput);
return;
}
if (sim.gliding) {
travelGliding(sim, movementInput);
return;
}
travelMidAir(sim, movementInput);
}
static void travelRemoteFlight(PlayerMovementSimulation sim, Vec3 movementInput) {
updateVelocity(sim, 0.045f, movementInput);
if (sim.shouldHoldVerticalMotion() && Math.abs(sim.velocity.y) < 0.12) {
sim.velocity = new Vec3(sim.velocity.x, 0.0, sim.velocity.z);
}
SneakAndClimbHelper.move(sim, sim.velocity);
double yDrag = sim.shouldHoldVerticalMotion() ? 0.35 : 0.91;
sim.velocity = sim.velocity.multiply(0.91, yDrag, 0.91);
if (sim.shouldHoldVerticalMotion() && Math.abs(sim.velocity.y) < 0.015) {
sim.velocity = new Vec3(sim.velocity.x, 0.0, sim.velocity.z);
}
sim.fallDistance = 0.0;
sim.onGround = false;
}
static void travelMidAir(PlayerMovementSimulation sim, Vec3 movementInput) {
BlockPos blockPos = sim.getVelocityAffectingPos();
float slipperiness = sim.player.level().getBlockState(blockPos).getBlock().getFriction();
float friction = sim.onGround ? slipperiness * 0.91f : 0.91f;
Vec3 movedVelocity = applyMovementInput(sim, movementInput, slipperiness);
double y = movedVelocity.y;
MobEffectInstance levitation = StatusEffectAccess.get(sim.player, MobEffects.LEVITATION);
if (levitation != null) {
y += (0.05 * (levitation.getAmplifier() + 1) - movedVelocity.y) * 0.2;
} else if (!sim.player.level().isClientSide() || sim.player.level().hasChunkAt(blockPos)) {
y -= getEffectiveGravity(sim);
} else if (sim.pos.y > sim.player.level().getMinY()) {
y = -0.1;
} else {
y = 0.0;
}
if (sim.player.shouldDiscardFriction()) {
sim.velocity = new Vec3(movedVelocity.x, y, movedVelocity.z);
} else {
sim.velocity = new Vec3(movedVelocity.x * friction, y * 0.9800000190734863, movedVelocity.z * friction);
}
}
static void travelGliding(PlayerMovementSimulation sim, Vec3 movementInput) {
if (SneakAndClimbHelper.isClimbing(sim)) {
travelMidAir(sim, movementInput);
sim.gliding = false;
return;
}
Vec3 oldVelocity = sim.velocity;
double horizontalSpeed = oldVelocity.horizontalDistance();
sim.velocity = calcGlidingVelocity(sim, oldVelocity);
SneakAndClimbHelper.move(sim, sim.velocity);
if (!sim.player.level().isClientSide()) {
double newHorizontalSpeed = sim.velocity.horizontalDistance();
if (sim.horizontalCollision && oldVelocity.horizontalDistance() - newHorizontalSpeed > 0.0) {
sim.gliding = false;
}
}
}
static Vec3 calcGlidingVelocity(PlayerMovementSimulation sim, Vec3 oldVelocity) {
Vec3 rotation = getRotationVector(sim);
float pitchRadians = sim.pitch * Mth.DEG_TO_RAD;
double horizontalLook = Math.sqrt(rotation.x * rotation.x + rotation.z * rotation.z);
double horizontalVelocity = oldVelocity.horizontalDistance();
double gravity = getEffectiveGravity(sim);
double lift = Mth.square(Math.cos(pitchRadians));
oldVelocity = oldVelocity.add(0.0, gravity * (-1.0 + lift * 0.75), 0.0);
if (oldVelocity.y < 0.0 && horizontalLook > 0.0) {
double dive = oldVelocity.y * -0.1 * lift;
oldVelocity = oldVelocity.add(rotation.x * dive / horizontalLook, dive, rotation.z * dive / horizontalLook);
}
if (pitchRadians < 0.0f && horizontalLook > 0.0) {
double climb = horizontalVelocity * -Mth.sin(pitchRadians) * 0.04;
oldVelocity = oldVelocity.add(-rotation.x * climb / horizontalLook, climb * 3.2, -rotation.z * climb / horizontalLook);
}
if (horizontalLook > 0.0) {
oldVelocity = oldVelocity.add(
(rotation.x / horizontalLook * horizontalVelocity - oldVelocity.x) * 0.1,
0.0,
(rotation.z / horizontalLook * horizontalVelocity - oldVelocity.z) * 0.1
);
}
return oldVelocity.multiply(0.99f, 0.98f, 0.99f);
}
static float getMovementSpeed(PlayerMovementSimulation sim, float slipperiness) {
if (sim.onGround) {
return sim.player.getSpeed() * (0.21600002f / (slipperiness * slipperiness * slipperiness));
}
return sim.sprinting ? 0.025999999f : 0.02f;
}
static void updateVelocity(PlayerMovementSimulation sim, float speed, Vec3 movementInput) {
Vec3 added = EntityInvoker.lovisual$movementInputToVelocity(movementInput, speed, sim.yaw);
sim.velocity = sim.velocity.add(added);
}
static Vec3 applyMovementInput(PlayerMovementSimulation sim, Vec3 movementInput, float slipperiness) {
updateVelocity(sim, getMovementSpeed(sim, slipperiness), movementInput);
sim.velocity = SneakAndClimbHelper.applyClimbingSpeed(sim, sim.velocity);
sim.velocity = FluidPhysicsHelper.applyWebSpeed(sim, sim.velocity);
SneakAndClimbHelper.move(sim, SneakAndClimbHelper.adjustMovementForSneaking(sim, sim.velocity));
Vec3 movedVelocity = sim.velocity;
if ((sim.horizontalCollision || sim.jumping) && SneakAndClimbHelper.isClimbing(sim)) {
movedVelocity = new Vec3(movedVelocity.x, 0.2, movedVelocity.z);
}
return movedVelocity;
}
static double getEffectiveGravity(PlayerMovementSimulation sim) {
double gravity = sim.player.getGravity();
if (sim.velocity.y <= 0.0 && StatusEffectAccess.has(sim.player, MobEffects.SLOW_FALLING)) {
return Math.min(gravity, 0.01);
}
return gravity;
}
static Vec3 getRotationVector(PlayerMovementSimulation sim) {
return Vec3.directionFromRotation(sim.pitch, sim.yaw);
}
}

View file

@ -0,0 +1,187 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.util.player.simulation.remote;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.player.Abilities;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.GameType;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.features.relations.StaffTracker;
import dev.loki.lovisual.util.player.effect.tracker.StatusEffectAccess;
public final class RemotePlayerObservation {
private static final int HOVER_CONFIRM_TICKS = 3;
private static final int FLIGHT_CONFIRM_TICKS = 4;
private static final double STABLE_VERTICAL_SPEED = 0.018;
private static final double FROZEN_HORIZONTAL_SPEED = 0.035;
private static final double CONTROLLED_HORIZONTAL_SPEED = 0.065;
private Vec3 lastPos;
private Vec3 measuredVelocity = Vec3.ZERO;
private int lastAge = Integer.MIN_VALUE;
private int hoverTicks;
private int flightLikeTicks;
private MotionKind motionKind = MotionKind.NORMAL;
private boolean serverFlightAllowed;
private boolean survivalFlightHeuristic;
private boolean holdVerticalMotion;
public RemotePlayerObservation(Player player) {
this.lastPos = player.position();
}
private static boolean hasActiveFlight(Player player) {
Abilities abilities = player.getAbilities();
return abilities != null && abilities.flying;
}
private static boolean hasServerFlightRight(Player player) {
Abilities abilities = player.getAbilities();
if (abilities != null && (abilities.mayfly || abilities.instabuild || abilities.flying)) {
return true;
}
GameType gameMode = resolveGameMode(player);
return gameMode == GameType.CREATIVE || gameMode == GameType.SPECTATOR;
}
private static GameType resolveGameMode(Player player) {
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.getConnection() != null) {
PlayerInfo entry = mc.getConnection().getPlayerInfo(player.getUUID());
if (entry != null && entry.getGameMode() != null) {
return entry.getGameMode();
}
}
StaffTracker.StaffInfo info = StaffTracker.INSTANCE.all().get(player.getUUID());
if (info != null && info.gm != null) {
return info.gm;
}
return GameType.DEFAULT_MODE;
}
public void update(Player player) {
int age = player.tickCount;
if (age == lastAge) {
return;
}
lastAge = age;
Vec3 current = player.position();
measuredVelocity = lastPos == null
? current.subtract(player.xo, player.yo, player.zo)
: current.subtract(lastPos);
lastPos = current;
boolean gliding = player.isFallFlying();
boolean airborne = !player.onGround() && !player.isPassenger();
boolean fluid = player.isInWater() || player.isInLava();
boolean hasLevitation = StatusEffectAccess.has(player, MobEffects.LEVITATION);
boolean hasSlowFalling = StatusEffectAccess.has(player, MobEffects.SLOW_FALLING);
boolean vanillaSpecialVertical = fluid || hasLevitation || hasSlowFalling;
double horizontalSpeed = Math.sqrt(measuredVelocity.x * measuredVelocity.x + measuredVelocity.z * measuredVelocity.z);
boolean stableVertical = Math.abs(measuredVelocity.y) <= STABLE_VERTICAL_SPEED;
boolean frozenHorizontal = horizontalSpeed <= FROZEN_HORIZONTAL_SPEED;
boolean controlledHorizontal = horizontalSpeed >= CONTROLLED_HORIZONTAL_SPEED;
serverFlightAllowed = hasServerFlightRight(player);
boolean activeAbilityFlight = hasActiveFlight(player);
if (airborne && !gliding && !vanillaSpecialVertical && stableVertical) {
hoverTicks++;
} else {
hoverTicks = 0;
}
survivalFlightHeuristic = airborne
&& !gliding
&& !vanillaSpecialVertical
&& stableVertical
&& controlledHorizontal
&& !serverFlightAllowed;
if (activeAbilityFlight || serverFlightAllowed && airborne && stableVertical || survivalFlightHeuristic) {
flightLikeTicks++;
} else if (!airborne || gliding || vanillaSpecialVertical) {
flightLikeTicks = 0;
} else {
flightLikeTicks = Math.max(0, flightLikeTicks - 1);
}
motionKind = resolveMotionKind(player, gliding, airborne, stableVertical, frozenHorizontal, activeAbilityFlight);
holdVerticalMotion = motionKind == MotionKind.FROZEN_AIR
|| motionKind == MotionKind.HOVER
|| stableVertical && (motionKind == MotionKind.SERVER_FLIGHT || motionKind == MotionKind.SURVIVAL_FLIGHT);
}
public MotionKind motionKind() {
return motionKind;
}
public Vec3 measuredVelocity() {
return measuredVelocity;
}
public boolean isFlightLike() {
return motionKind == MotionKind.SERVER_FLIGHT
|| motionKind == MotionKind.SURVIVAL_FLIGHT
|| motionKind == MotionKind.HOVER
|| motionKind == MotionKind.FROZEN_AIR;
}
public boolean shouldHoldVerticalMotion() {
return holdVerticalMotion;
}
private MotionKind resolveMotionKind(Player player,
boolean gliding,
boolean airborne,
boolean stableVertical,
boolean frozenHorizontal,
boolean activeAbilityFlight) {
if (gliding) {
return MotionKind.GLIDING;
}
if (!airborne) {
return MotionKind.NORMAL;
}
if (hoverTicks >= HOVER_CONFIRM_TICKS && frozenHorizontal) {
return MotionKind.FROZEN_AIR;
}
if (activeAbilityFlight || serverFlightAllowed && flightLikeTicks >= 1) {
return MotionKind.SERVER_FLIGHT;
}
if (flightLikeTicks >= FLIGHT_CONFIRM_TICKS && survivalFlightHeuristic) {
return MotionKind.SURVIVAL_FLIGHT;
}
if (hoverTicks >= HOVER_CONFIRM_TICKS && stableVertical) {
return MotionKind.HOVER;
}
return MotionKind.NORMAL;
}
public enum MotionKind {
NORMAL,
GLIDING,
SERVER_FLIGHT,
SURVIVAL_FLIGHT,
HOVER,
FROZEN_AIR
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.player.simulation.remote;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import dev.loki.lovisual.util.player.simulation.PlayerMovementSimulation;
import dev.loki.lovisual.util.player.simulation.remote.RemotePlayerObservation;
import dev.loki.lovisual.util.player.simulation.SimulatedInput;
public final class RemotePlayerSimulation extends PlayerMovementSimulation {
private final RemotePlayerObservation observation;
public RemotePlayerSimulation(Player player, SimulatedInput input, RemotePlayerObservation observation) {
super(player, input);
this.observation = observation;
primeFlightLikeState(player);
}
@Override
protected boolean shouldUseRemoteFlightPhysics() {
return observation.isFlightLike();
}
@Override
protected boolean shouldHoldVerticalMotion() {
return observation.shouldHoldVerticalMotion();
}
@Override
protected RemotePlayerObservation.MotionKind motionKind() {
return observation.motionKind();
}
private void primeFlightLikeState(Player player) {
if (!observation.isFlightLike()) {
return;
}
Vec3 measured = observation.measuredVelocity();
if (measured == null) {
return;
}
// The vanilla simulator derives initial velocity from lastX/Y/Z. For server-side
// hover/fly this is often exactly the useful value, but vertical motion needs to be
// clamped or the next predicted ticks will incorrectly turn a hovering target into a
// falling target.
if (observation.shouldHoldVerticalMotion() && Math.abs(measured.y) < 0.08) {
measured = new Vec3(measured.x, 0.0, measured.z);
}
setSimulatedVelocity(measured);
setSimulatedFallDistance(0.0);
setSimulatedOnGround(false);
setSimulatedGliding(false);
}
}

View file

@ -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.player.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.FluidTags;
import net.minecraft.util.Mth;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
final class FluidStateHelper {
private FluidStateHelper() {
}
static void updateWaterState(PlayerMovementSimulation sim) {
sim.waterHeight = 0.0;
sim.lavaHeight = 0.0;
sim.touchingWater = updateMovementInFluid(sim, true, 0.014);
updateMovementInFluid(sim, false, 0.0023333333333333335);
}
static void updateSubmergedInWaterState(PlayerMovementSimulation sim) {
double eyeY = sim.pos.y + sim.player.getEyeHeight(sim.player.getPose());
BlockPos blockPos = BlockPos.containing(sim.pos.x, eyeY, sim.pos.z);
FluidState fluidState = sim.player.level().getFluidState(blockPos);
double fluidY = blockPos.getY() + fluidState.getHeight(sim.player.level(), blockPos);
sim.submergedInWater = fluidState.is(FluidTags.WATER) && fluidY > eyeY;
}
static void updateSwimming(PlayerMovementSimulation sim) {
if (sim.player.getAbilities().flying) {
sim.swimming = false;
return;
}
if (sim.swimming) {
sim.swimming = sim.sprinting && sim.touchingWater && !sim.player.isPassenger();
} else {
FluidState state = sim.player.level().getFluidState(BlockPos.containing(sim.pos));
sim.swimming = sim.sprinting && sim.submergedInWater && !sim.player.isPassenger() && state.is(FluidTags.WATER);
}
}
static boolean updateMovementInFluid(PlayerMovementSimulation sim, boolean water, double speed) {
Level world = sim.player.level();
if (isRegionUnloaded(sim, world)) {
return false;
}
AABB box = sim.boundingBox.deflate(0.001);
int minX = Mth.floor(box.minX);
int maxX = Mth.ceil(box.maxX);
int minY = Mth.floor(box.minY);
int maxY = Mth.ceil(box.maxY);
int minZ = Mth.floor(box.minZ);
int maxZ = Mth.ceil(box.maxZ);
double maxHeight = 0.0;
boolean found = false;
Vec3 addedVelocity = Vec3.ZERO;
int samples = 0;
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 = world.getFluidState(mutable);
if (!(water ? fluidState.is(FluidTags.WATER) : fluidState.is(FluidTags.LAVA))) {
continue;
}
double height = y + fluidState.getHeight(world, mutable);
if (height < box.minY) {
continue;
}
found = true;
maxHeight = Math.max(height - box.minY, maxHeight);
Vec3 fluidVelocity = fluidState.getFlow(world, mutable);
if (maxHeight < 0.4) {
fluidVelocity = fluidVelocity.scale(maxHeight);
}
addedVelocity = addedVelocity.add(fluidVelocity);
samples++;
}
}
}
if (samples > 0) {
addedVelocity = addedVelocity.scale(1.0 / samples).scale(speed);
if (Math.abs(sim.velocity.x) < 0.003 && Math.abs(sim.velocity.z) < 0.003 && addedVelocity.length() < 0.0045) {
addedVelocity = addedVelocity.normalize().scale(0.0045);
}
if (addedVelocity.lengthSqr() > 0.0) {
sim.velocity = sim.velocity.add(addedVelocity);
}
}
if (water) {
sim.waterHeight = maxHeight;
} else {
sim.lavaHeight = maxHeight;
}
return found;
}
private static boolean isRegionUnloaded(PlayerMovementSimulation sim, Level world) {
AABB box = sim.boundingBox.inflate(1.0);
int minX = Mth.floor(box.minX);
int maxX = Mth.ceil(box.maxX);
int minZ = Mth.floor(box.minZ);
int maxZ = Mth.ceil(box.maxZ);
for (int x = minX >> 4; x <= (maxX - 1) >> 4; x++) {
for (int z = minZ >> 4; z <= (maxZ - 1) >> 4; z++) {
LevelChunk chunk = world.getChunkSource().getChunkNow(x, z);
if (chunk == null) {
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,194 @@
/*
* 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.player.simulation;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.BubbleColumnBlock;
import net.minecraft.world.level.block.LadderBlock;
import net.minecraft.world.level.block.TrapDoorBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import java.util.List;
final class SneakAndClimbHelper {
private SneakAndClimbHelper() {
}
static Vec3 adjustMovementForSneaking(PlayerMovementSimulation sim, Vec3 movement) {
if (movement.y > 0.0 || !shouldConsiderLedgeClipping(sim)) {
return movement;
}
double x = movement.x;
double z = movement.z;
while (x != 0.0 && sim.player.level().noCollision(sim.player, sim.boundingBox.move(x, -PlayerMovementSimulation.STEP_HEIGHT, 0.0))) {
if (x < 0.05 && x >= -0.05) {
x = 0.0;
} else if (x > 0.0) {
x -= 0.05;
} else {
x += 0.05;
}
}
while (z != 0.0 && sim.player.level().noCollision(sim.player, sim.boundingBox.move(0.0, -PlayerMovementSimulation.STEP_HEIGHT, z))) {
if (z < 0.05 && z >= -0.05) {
z = 0.0;
} else if (z > 0.0) {
z -= 0.05;
} else {
z += 0.05;
}
}
while (x != 0.0 && z != 0.0 && sim.player.level().noCollision(sim.player, sim.boundingBox.move(x, -PlayerMovementSimulation.STEP_HEIGHT, z))) {
if (x < 0.05 && x >= -0.05) {
x = 0.0;
} else if (x > 0.0) {
x -= 0.05;
} else {
x += 0.05;
}
if (z < 0.05 && z >= -0.05) {
z = 0.0;
} else if (z > 0.0) {
z -= 0.05;
} else {
z += 0.05;
}
}
if (movement.x != x || movement.z != z) {
sim.clipLedged = true;
}
if (shouldClipAtLedge(sim)) {
return new Vec3(x, movement.y, z);
}
return movement;
}
static boolean shouldClipAtLedge(PlayerMovementSimulation sim) {
return !sim.input.ignoreClippingAtLedge && (sim.input.sneak || sim.input.forceSafeWalk);
}
static boolean shouldConsiderLedgeClipping(PlayerMovementSimulation sim) {
return sim.onGround || sim.fallDistance < PlayerMovementSimulation.STEP_HEIGHT && !sim.player.level().noCollision(
sim.player,
sim.boundingBox.move(0.0, sim.fallDistance - PlayerMovementSimulation.STEP_HEIGHT, 0.0)
);
}
static Vec3 applyClimbingSpeed(PlayerMovementSimulation sim, Vec3 motion) {
if (!isClimbing(sim)) {
return motion;
}
double x = Mth.clamp(motion.x, -0.15f, 0.15f);
double z = Mth.clamp(motion.z, -0.15f, 0.15f);
double y = Math.max(motion.y, -0.15f);
if (y < 0.0 && sim.player.isShiftKeyDown()) {
y = 0.0;
}
return new Vec3(x, y, z);
}
static boolean isClimbing(PlayerMovementSimulation sim) {
if (sim.player.getAbilities().flying || sim.player.isSpectator()) {
return false;
}
BlockPos blockPos = BlockPos.containing(sim.pos);
BlockState blockState = sim.player.level().getBlockState(blockPos);
if (sim.gliding && blockState.is(BlockTags.CAN_GLIDE_THROUGH)) {
return false;
}
if (blockState.is(BlockTags.CLIMBABLE)) {
return true;
}
return blockState.getBlock() instanceof TrapDoorBlock && canEnterTrapdoor(sim, blockPos, blockState);
}
static float getVelocityMultiplier(PlayerMovementSimulation sim) {
if (sim.player.getAbilities().flying || sim.gliding) {
return 1.0f;
}
BlockState state = sim.player.level().getBlockState(BlockPos.containing(sim.pos));
float multiplier = state.getBlock().getSpeedFactor();
if (!state.is(Blocks.WATER) && !state.is(Blocks.BUBBLE_COLUMN)) {
if (multiplier == 1.0f) {
multiplier = sim.player.level().getBlockState(sim.getVelocityAffectingPos()).getBlock().getSpeedFactor();
}
}
return multiplier;
}
private static boolean canEnterTrapdoor(PlayerMovementSimulation sim, BlockPos pos, BlockState state) {
if (!(Boolean) state.getValue(TrapDoorBlock.OPEN)) {
return false;
}
BlockState belowState = sim.player.level().getBlockState(pos.below());
return belowState.is(Blocks.LADDER) && belowState.getValue(LadderBlock.FACING) == state.getValue(TrapDoorBlock.FACING);
}
static void move(PlayerMovementSimulation sim, Vec3 movement) {
Vec3 adjusted = movement.lengthSqr() == 0.0
? movement
: Entity.collideBoundingBox(sim.player, movement, sim.boundingBox, sim.player.level(), List.of());
if (adjusted.lengthSqr() > 1.0E-7 || movement.lengthSqr() - adjusted.lengthSqr() < 1.0E-7) {
sim.pos = sim.pos.add(adjusted);
EntityDimensions dimensions = sim.player.getDimensions(sim.player.getPose());
sim.boundingBox = dimensions.makeBoundingBox(sim.pos);
}
boolean xCollision = movement.x != adjusted.x;
boolean yCollision = movement.y != adjusted.y;
boolean zCollision = movement.z != adjusted.z;
sim.horizontalCollision = xCollision || zCollision;
sim.onGround = yCollision && movement.y < 0.0;
if (sim.onGround) {
sim.fallDistance = 0.0;
} else if (adjusted.y < 0.0) {
sim.fallDistance -= adjusted.y;
}
if (xCollision || zCollision || yCollision) {
sim.velocity = new Vec3(xCollision ? 0.0 : sim.velocity.x, yCollision ? 0.0 : sim.velocity.y, zCollision ? 0.0 : sim.velocity.z);
}
float velocityMultiplier = getVelocityMultiplier(sim);
sim.velocity = new Vec3(sim.velocity.x * velocityMultiplier, sim.velocity.y, sim.velocity.z * velocityMultiplier);
}
static void applyBubbleColumnEffects(PlayerMovementSimulation sim) {
BlockPos pos = BlockPos.containing(sim.pos);
BlockState state = sim.player.level().getBlockState(pos);
if (!state.is(Blocks.BUBBLE_COLUMN)) {
return;
}
BlockState above = sim.player.level().getBlockState(pos.above());
boolean surface = above.getCollisionShape(sim.player.level(), pos).isEmpty() && above.getFluidState().isEmpty();
boolean drag = state.getValue(BubbleColumnBlock.DRAG_DOWN);
double y;
if (surface) {
y = drag ? Math.max(-0.9, sim.velocity.y - 0.03) : Math.min(1.8, sim.velocity.y + 0.1);
} else {
y = drag ? Math.max(-0.3, sim.velocity.y - 0.03) : Math.min(0.7, sim.velocity.y + 0.06);
sim.onGround = true;
}
sim.velocity = new Vec3(sim.velocity.x, y, sim.velocity.z);
}
}