chore(history): squash 59 commit(s) from 2026-09-23
- refactor(hud/weather/render): Scoreboard 931→240 + Rain 903→159 + UiMeshGeometry 956→99 (8.5.2) - refactor(chams): Chams 985 → 129 + 4×≤195 (8.5.2) - refactor(module): Module 1031 → 139 + 4×≤134 (8.5.2) - refactor(tooltips): BetterTooltips 960 → 181 + 4×≤199 (8.5.2) - refactor(clickgui): ClickGuiPickerState 970→166 + ModulesMenuScreen 966→145 (8.5.2) - refactor(clickgui): CombatProtocolHeuristicsEditorState 932→79 + 4×≤192 (8.5.2) - refactor(hud): CompactHudStatModel 894 → 125 + 4×≤175 (8.5.2) - refactor(iris): ShaderPatchCompiler 843→66 + 4×≤174 (8.5.2) - refactor(tab): CustomTabList 834→136 + 4×≤166 (8.5.2) - refactor(relations): OnlineRelationPlayerPickerComponent 837→74 + 4×≤192 (8.5.2) - refactor(hits): HitEffect 770→120 + 4×≤176 (8.5.2) - refactor(predict): Predictions 815→150 + 4×≤195 (8.5.2) - refactor(config): ConfigProfilesComponent 799→125 + 4×≤195 (8.5.2) - refactor(sim): PlayerMovementSimulation 757→157 + 4×≤194 (8.5.2) - refactor(predict): ProjectilePuncher 747→104 + 3×≤134 (8.5.2) - refactor(crosshair): Crosshair 743→143 + 4×≤167 (8.5.2) - refactor(mediaplayer): MediaPlayer 743→174 + 4×≤196 (8.5.2) - refactor(hud): Itemizer 699→193 + Cooldowns 707→151 (8.5.2) - refactor(hud): Potions 617→176 + 4×≤166 (8.5.2) - refactor(hud): Admins 702→118 + 4×≤138 (8.5.2) - refactor(hud): Keybinds 611→157 + 4×≤160 (8.5.2) - refactor(hud): ModuleList 499→181 + 3×≤150 (8.5.2) - refactor(hud): Radar 392→161 + 2×≤124 (8.5.2) - refactor(hud): Fps 309→168 + 3×≤107 (8.5.2) - refactor(hud): Memory 304→151 + 3×≤144 (8.5.2) - refactor(hud): Tps 339→154 + 3×≤143 (8.5.2) - refactor(hud): Ping 309→145 + 3×≤125 (8.5.2) - refactor(hud): SpeedBps 334→156 + 3×≤142 (8.5.2) - refactor(hud): GameTime 314→150 + 3×≤127 (8.5.2) - refactor(hud): SystemTime 300→136 + 3×≤130 (8.5.2) - refactor(hud): Coordinates 337→174 + 2×≤172 (8.5.2) - refactor(hud): Armor 324→162 + 2×≤148 (8.5.2) - refactor(hud): Inventory 476→153 + 3×≤181 (8.5.2) - docs(todo): обновлён 8.5.2 — отмечены готовые гиганты (BetterButtons/InventorySwap/Svg/Темы/WorldParticles/Module/Chams и хвост) - docs(api): папка combatant-client-26.2/docs + addons.md (API можно ломать при рефакторе) - refactor(visuals): BlockHighlight 692→271 + 4×≤200 (8.5.2) - refactor(mixins): GameRendererMixin 830→344 (хуки) + 4 хелпера (8.5.2) - refactor(visuals): WorldParticles 821→260 + 6×≤200, режимы добиты (8.5.2) - docs(todo): 8.5.2 — WorldParticles/GameRendererMixin/BlockHighlight отмечены ГОТОВО - refactor(render): MeshBuilder 738→447 (фасад write-API) + 3 хелпера, FrameStats снесён (8.5.2) - docs(todo): Фаза 10 — дизайн платформы (аккаунты/конфиги/аватарки), backlog RPC-чата - docs(todo): Подсистема 3 — Аддоны 2.0 (скрипты/версии) + витрина аддонов + админка - docs(todo): Подсистема 2 — RPC-чат/друзья/виджеты-телеметрия + рейт-лимиты, доп. правки Подсистемы 3 - chore: переименование combatant-client-26.2 -> mod, добавлены backend/ и frontend/ - docs(backend): implementation plan for accounts-service (Подсистема 1, часть 1) - chore: пересоздать backend/frontend через cargo new и bun create vite + tailwind; убрать таблицу компонентов из TODO.md - docs(backend): edition 2024 в плане вместо 2021 - chore: обновить версии до реально актуальных (проверено компиляцией) - docs: правила платформы (лимит 250 строк, антипаттерны backend/frontend по итогам ресёрча) + frontend/ARCHITECTURE.md - docs: добавить правило ≤4 файла на папку в правила платформы (backend/frontend) - docs: commit messages in English from now on - chore: gitignore .superpowers/ scratch directory - docs: commit messages in English from now on (mod) - feat(backend): scaffold accounts-service with health check - chore(backend): remove target/ build artifacts from git, add gitignore - chore: track docs/ in git (was accidentally gitignored, never committed) - chore(backend): convert to Cargo workspace, plan full microservice layout - feat(backend): accounts/avatars/device_links schema + migration - feat(backend): argon2id password hashing
This commit is contained in:
parent
ec10395511
commit
9d08fa910a
2239 changed files with 35265 additions and 28110 deletions
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue