feat: add KineticLyrics 3D lyrics rendering system
|
|
@ -60,7 +60,6 @@ import dev.loki.lovisual.util.click.AttackPressing;
|
|||
import dev.loki.lovisual.util.combat.AntiBotTracker;
|
||||
import dev.loki.lovisual.util.combat.protocol.CombatProtocolHeuristics;
|
||||
import dev.loki.lovisual.util.combat.SprintController;
|
||||
import dev.loki.lovisual.util.combat.VulcanReachController;
|
||||
import dev.loki.lovisual.util.entity.simulation.BoatSimulationCache;
|
||||
import dev.loki.lovisual.util.media.MediaSessionService;
|
||||
import dev.loki.lovisual.util.network.BacktrackController;
|
||||
|
|
@ -318,7 +317,6 @@ public class LoVisual implements ClientModInitializer {
|
|||
Events.BUS.register(InventorySwap.INSTANCE);
|
||||
Events.BUS.register(SprintController.INSTANCE);
|
||||
Events.BUS.register(AntiBotTracker.INSTANCE);
|
||||
Events.BUS.register(VulcanReachController.INSTANCE);
|
||||
Events.BUS.register(CombatProtocolHeuristics.INSTANCE);
|
||||
Events.BUS.register(PvpTracker.INSTANCE);
|
||||
Events.BUS.register(PvpTargetTracker.INSTANCE);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
|
||||
public final class CosmeticAttachmentTransforms {
|
||||
private CosmeticAttachmentTransforms() {
|
||||
}
|
||||
|
||||
public static void apply(PoseStack matrices, CosmeticType type, CosmeticModel model) {
|
||||
if (type == CosmeticType.PET) {
|
||||
// TODO: replace with CosmeticAnimator.getPetHoverOffset() once the animator is ported.
|
||||
float bob = 0f;
|
||||
matrices.mulPose(Axis.YP.rotationDegrees(180f));
|
||||
matrices.translate(0.45f, 2.15f + bob, 0.12f);
|
||||
matrices.scale(0.55f, 0.55f, 0.55f);
|
||||
return;
|
||||
}
|
||||
if (type == CosmeticType.ACCESSORY && model != null) {
|
||||
String folder = model.getFolderName().toLowerCase();
|
||||
if (folder.contains("witch") || folder.contains("hat")) {
|
||||
matrices.translate(0f, 1.72f, 0f);
|
||||
matrices.scale(0.52f, 0.52f, 0.52f);
|
||||
} else if (folder.contains("wing")) {
|
||||
matrices.translate(0f, 0.1f, -0.3f);
|
||||
matrices.mulPose(Axis.YP.rotationDegrees(180f));
|
||||
matrices.scale(0.85f, 0.85f, 0.85f);
|
||||
} else if (folder.contains("wheel")) {
|
||||
matrices.translate(0f, 0.55f, 0.25f);
|
||||
matrices.scale(0.55f, 0.55f, 0.55f);
|
||||
} else {
|
||||
matrices.translate(0f, 1.3f, 0f);
|
||||
matrices.scale(0.5f, 0.5f, 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void applyPreview(PoseStack matrices, CosmeticModel model) {
|
||||
if (model == null) return;
|
||||
String folder = model.getFolderName().toLowerCase();
|
||||
if (model.getType() == CosmeticType.PET) {
|
||||
matrices.translate(6f, 22f, 1f);
|
||||
matrices.scale(1.8f, 1.8f, 1.8f);
|
||||
} else if (model.getType() == CosmeticType.ACCESSORY) {
|
||||
if (folder.contains("witch") || folder.contains("hat")) {
|
||||
matrices.translate(0f, 20f, 0f);
|
||||
} else if (folder.contains("wing")) {
|
||||
matrices.translate(0f, 2f, -4f);
|
||||
matrices.scale(1.3f, 1.3f, 1.3f);
|
||||
} else if (folder.contains("wheel")) {
|
||||
matrices.translate(0f, 6f, 3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CosmeticBbAnimation {
|
||||
private final String name;
|
||||
private final float length;
|
||||
private final boolean loop;
|
||||
private final Map<String, BoneTrack> tracksByBoneUuid = new java.util.HashMap<>();
|
||||
|
||||
public CosmeticBbAnimation(String name, float length, boolean loop) {
|
||||
this.name = name;
|
||||
this.length = Math.max(length, 1f / 20f);
|
||||
this.loop = loop;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public float getLength() {
|
||||
return length;
|
||||
}
|
||||
|
||||
public boolean isLoop() {
|
||||
return loop;
|
||||
}
|
||||
|
||||
public Map<String, BoneTrack> getTracks() {
|
||||
return tracksByBoneUuid;
|
||||
}
|
||||
|
||||
public static Map<String, CosmeticBbAnimation> parseAll(JsonObject bbmodel) {
|
||||
Map<String, CosmeticBbAnimation> map = new LinkedHashMap<>();
|
||||
if (!bbmodel.has("animations")) return map;
|
||||
|
||||
for (JsonElement el : bbmodel.getAsJsonArray("animations")) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject obj = el.getAsJsonObject();
|
||||
String name = obj.has("name") ? obj.get("name").getAsString() : "anim";
|
||||
if (name.startsWith("--") || name.isBlank()) continue;
|
||||
|
||||
float length = obj.has("length") ? obj.get("length").getAsFloat() : 1f;
|
||||
boolean loop = !obj.has("loop") || obj.get("loop").getAsBoolean();
|
||||
CosmeticBbAnimation anim = new CosmeticBbAnimation(name, length, loop);
|
||||
|
||||
if (obj.has("animators")) {
|
||||
JsonObject animators = obj.getAsJsonObject("animators");
|
||||
for (Map.Entry<String, JsonElement> entry : animators.entrySet()) {
|
||||
if (!entry.getValue().isJsonObject()) continue;
|
||||
JsonObject boneAnim = entry.getValue().getAsJsonObject();
|
||||
String boneUuid = entry.getKey();
|
||||
if (boneAnim.has("uuid")) {
|
||||
boneUuid = boneAnim.get("uuid").getAsString();
|
||||
}
|
||||
BoneTrack track = BoneTrack.parse(boneAnim);
|
||||
if (!track.isEmpty()) {
|
||||
anim.tracksByBoneUuid.put(boneUuid, track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anim.tracksByBoneUuid.isEmpty()) continue;
|
||||
map.put(name.toLowerCase(), anim);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public static void apply(CosmeticModel model, CosmeticBbAnimation animation, float time) {
|
||||
java.util.Set<String> filter = model.getAnimationBoneFilter();
|
||||
for (Map.Entry<String, BoneTrack> entry : animation.tracksByBoneUuid.entrySet()) {
|
||||
if (filter != null && !filter.contains(entry.getKey())) continue;
|
||||
CosmeticBone bone = model.getBoneByUuid(entry.getKey());
|
||||
if (bone == null) continue;
|
||||
|
||||
BoneTrack track = entry.getValue();
|
||||
Vector3f rot = track.sampleRotation(time, animation.length, animation.loop);
|
||||
Vector3f pos = track.samplePosition(time, animation.length, animation.loop);
|
||||
|
||||
if (rot != null) {
|
||||
bone.setAnimRotation(new Vector3f(
|
||||
rot.x - bone.getDefaultRotation().x,
|
||||
rot.y - bone.getDefaultRotation().y,
|
||||
rot.z - bone.getDefaultRotation().z
|
||||
));
|
||||
}
|
||||
if (pos != null) {
|
||||
bone.setAnimPosition(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class BoneTrack {
|
||||
private final List<Keyframe> rotations = new ArrayList<>();
|
||||
private final List<Keyframe> positions = new ArrayList<>();
|
||||
|
||||
static BoneTrack parse(JsonObject boneAnim) {
|
||||
BoneTrack track = new BoneTrack();
|
||||
if (!boneAnim.has("keyframes")) return track;
|
||||
|
||||
for (JsonElement el : boneAnim.getAsJsonArray("keyframes")) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject kf = el.getAsJsonObject();
|
||||
String channel = kf.has("channel") ? kf.get("channel").getAsString() : "";
|
||||
float t = kf.has("time") ? kf.get("time").getAsFloat() : 0f;
|
||||
Vector3f value = readPoint(kf);
|
||||
String interp = kf.has("interpolation") ? kf.get("interpolation").getAsString() : "linear";
|
||||
Keyframe keyframe = new Keyframe(t, value, interp);
|
||||
|
||||
switch (channel) {
|
||||
case "rotation" -> track.rotations.add(keyframe);
|
||||
case "position" -> track.positions.add(keyframe);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
track.rotations.sort(Comparator.comparing(Keyframe::time));
|
||||
track.positions.sort(Comparator.comparing(Keyframe::time));
|
||||
return track;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return rotations.isEmpty() && positions.isEmpty();
|
||||
}
|
||||
|
||||
Vector3f sampleRotation(float time, float length, boolean loop) {
|
||||
return sample(rotations, time, length, loop);
|
||||
}
|
||||
|
||||
Vector3f samplePosition(float time, float length, boolean loop) {
|
||||
return sample(positions, time, length, loop);
|
||||
}
|
||||
|
||||
private static Vector3f sample(List<Keyframe> frames, float time, float length, boolean loop) {
|
||||
if (frames.isEmpty()) return null;
|
||||
if (frames.size() == 1) return new Vector3f(frames.get(0).value);
|
||||
|
||||
float t = loop ? wrapTime(time, length) : Math.min(time, length);
|
||||
Keyframe prev = frames.get(0);
|
||||
Keyframe next = frames.get(frames.size() - 1);
|
||||
|
||||
for (int i = 0; i < frames.size() - 1; i++) {
|
||||
Keyframe a = frames.get(i);
|
||||
Keyframe b = frames.get(i + 1);
|
||||
if (t >= a.time && t <= b.time) {
|
||||
prev = a;
|
||||
next = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (t >= frames.get(frames.size() - 1).time) {
|
||||
prev = frames.get(frames.size() - 1);
|
||||
next = prev;
|
||||
}
|
||||
|
||||
if (prev == next || Math.abs(next.time - prev.time) < 0.0001f) {
|
||||
return new Vector3f(prev.value);
|
||||
}
|
||||
|
||||
float alpha = (t - prev.time) / (next.time - prev.time);
|
||||
if ("step".equals(prev.interpolation)) {
|
||||
return new Vector3f(prev.value);
|
||||
}
|
||||
return lerp(prev.value, next.value, clamp01(alpha));
|
||||
}
|
||||
|
||||
private static float wrapTime(float time, float length) {
|
||||
if (length <= 0f) return time;
|
||||
float wrapped = time % length;
|
||||
return wrapped < 0f ? wrapped + length : wrapped;
|
||||
}
|
||||
|
||||
private static float clamp01(float v) {
|
||||
return Math.max(0f, Math.min(1f, v));
|
||||
}
|
||||
|
||||
private static Vector3f lerp(Vector3f a, Vector3f b, float t) {
|
||||
return new Vector3f(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t,
|
||||
a.z + (b.z - a.z) * t
|
||||
);
|
||||
}
|
||||
|
||||
private static Vector3f readPoint(JsonObject kf) {
|
||||
if (!kf.has("data_points")) return new Vector3f();
|
||||
JsonArray points = kf.getAsJsonArray("data_points");
|
||||
if (points.isEmpty()) return new Vector3f();
|
||||
JsonObject point = points.get(0).getAsJsonObject();
|
||||
return new Vector3f(
|
||||
parseNum(point, "x"),
|
||||
parseNum(point, "y"),
|
||||
parseNum(point, "z")
|
||||
);
|
||||
}
|
||||
|
||||
private static float parseNum(JsonObject obj, String key) {
|
||||
if (!obj.has(key)) return 0f;
|
||||
JsonElement el = obj.get(key);
|
||||
if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isNumber()) {
|
||||
return el.getAsFloat();
|
||||
}
|
||||
try {
|
||||
return Float.parseFloat(el.getAsString().trim());
|
||||
} catch (Exception ignored) {
|
||||
return 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Keyframe(float time, Vector3f value, String interpolation) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
public class CosmeticBone {
|
||||
private final String name;
|
||||
private final String uuid;
|
||||
private final Vector3f pivot;
|
||||
private final Vector3f defaultRotation;
|
||||
private final List<CosmeticCube> cubes = new ArrayList<>();
|
||||
private final List<CosmeticBone> children = new ArrayList<>();
|
||||
|
||||
@Setter private Vector3f animRotation = new Vector3f(0, 0, 0);
|
||||
@Setter private Vector3f animPosition = new Vector3f(0, 0, 0);
|
||||
|
||||
public CosmeticBone(String name, String uuid, Vector3f pivot, Vector3f defaultRotation) {
|
||||
this.name = name;
|
||||
this.uuid = uuid;
|
||||
this.pivot = pivot;
|
||||
this.defaultRotation = defaultRotation;
|
||||
}
|
||||
|
||||
public Matrix4f buildMatrix() {
|
||||
Matrix4f mat = new Matrix4f();
|
||||
|
||||
mat.translate(pivot.x / 16f, pivot.y / 16f, pivot.z / 16f);
|
||||
|
||||
if (defaultRotation.x != 0 || defaultRotation.y != 0 || defaultRotation.z != 0
|
||||
|| animRotation.x != 0 || animRotation.y != 0 || animRotation.z != 0) {
|
||||
float rx = (float) Math.toRadians(defaultRotation.x + animRotation.x);
|
||||
float ry = (float) Math.toRadians(defaultRotation.y + animRotation.y);
|
||||
float rz = (float) Math.toRadians(defaultRotation.z + animRotation.z);
|
||||
mat.rotateZYX(rz, ry, rx);
|
||||
}
|
||||
|
||||
mat.translate(-pivot.x / 16f, -pivot.y / 16f, -pivot.z / 16f);
|
||||
|
||||
if (animPosition.x != 0 || animPosition.y != 0 || animPosition.z != 0) {
|
||||
mat.translate(animPosition.x / 16f, animPosition.y / 16f, animPosition.z / 16f);
|
||||
}
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
public void resetAnim() {
|
||||
animRotation = new Vector3f(0, 0, 0);
|
||||
animPosition = new Vector3f(0, 0, 0);
|
||||
for (CosmeticBone child : children) child.resetAnim();
|
||||
}
|
||||
|
||||
public CosmeticBone deepClone() {
|
||||
CosmeticBone copy = new CosmeticBone(name, uuid, new Vector3f(pivot), new Vector3f(defaultRotation));
|
||||
copy.getCubes().addAll(cubes);
|
||||
for (CosmeticBone child : children) {
|
||||
copy.getChildren().add(child.deepClone());
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
@Getter
|
||||
public class CosmeticCube {
|
||||
private final Vector3f from;
|
||||
private final Vector3f to;
|
||||
private final Vector3f rotation;
|
||||
private final Vector3f origin;
|
||||
private final float inflate;
|
||||
private final CubeFace[] faces;
|
||||
private final int textureWidth;
|
||||
private final int textureHeight;
|
||||
|
||||
public CosmeticCube(Vector3f from, Vector3f to, Vector3f rotation, Vector3f origin,
|
||||
float inflate, CubeFace[] faces, int textureWidth, int textureHeight) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.rotation = rotation;
|
||||
this.origin = origin;
|
||||
this.inflate = inflate;
|
||||
this.faces = faces;
|
||||
this.textureWidth = textureWidth;
|
||||
this.textureHeight = textureHeight;
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class CubeFace {
|
||||
private final float u1, v1, u2, v2;
|
||||
private final int textureIndex;
|
||||
|
||||
public CubeFace(float u1, float v1, float u2, float v2, int textureIndex) {
|
||||
this.u1 = u1;
|
||||
this.v1 = v1;
|
||||
this.u2 = u2;
|
||||
this.v2 = v2;
|
||||
this.textureIndex = textureIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
/**
|
||||
* Holds which cosmetic (model/pet/accessory) the local user currently has equipped.
|
||||
* Rendering and animation live in separate classes (not part of this core pass).
|
||||
*/
|
||||
public final class CosmeticManager {
|
||||
private static final CosmeticManager INSTANCE = new CosmeticManager();
|
||||
|
||||
private CosmeticModel activeModel;
|
||||
private CosmeticModel activePet;
|
||||
private CosmeticModel activeAccessory;
|
||||
|
||||
private CosmeticManager() {
|
||||
}
|
||||
|
||||
public static CosmeticManager get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the catalog from disk, re-resolving the currently equipped cosmetics
|
||||
* (by id) against the freshly loaded models.
|
||||
*/
|
||||
public void init() {
|
||||
String modelId = activeModel != null ? activeModel.getId() : null;
|
||||
String petId = activePet != null ? activePet.getId() : null;
|
||||
String accessoryId = activeAccessory != null ? activeAccessory.getId() : null;
|
||||
|
||||
for (CosmeticModel model : CosmeticRepository.get().all()) {
|
||||
model.resetTexture();
|
||||
}
|
||||
CosmeticRepository.get().reload();
|
||||
|
||||
CosmeticRepository repo = CosmeticRepository.get();
|
||||
activeModel = modelId != null ? repo.get(modelId) : null;
|
||||
activePet = petId != null ? repo.get(petId) : null;
|
||||
activeAccessory = accessoryId != null ? repo.get(accessoryId) : null;
|
||||
}
|
||||
|
||||
public CosmeticModel getActive(CosmeticType type) {
|
||||
return switch (type) {
|
||||
case PET -> activePet;
|
||||
case ACCESSORY -> activeAccessory;
|
||||
case MODEL -> activeModel;
|
||||
};
|
||||
}
|
||||
|
||||
public void setActive(CosmeticType type, CosmeticModel model) {
|
||||
if (model != null) {
|
||||
CosmeticModel resolved = CosmeticRepository.get().get(model.getId());
|
||||
if (resolved != null) {
|
||||
model = resolved;
|
||||
}
|
||||
}
|
||||
switch (type) {
|
||||
case PET -> activePet = model;
|
||||
case ACCESSORY -> activeAccessory = model;
|
||||
case MODEL -> activeModel = model;
|
||||
}
|
||||
}
|
||||
|
||||
public void clear(CosmeticType type) {
|
||||
setActive(type, null);
|
||||
}
|
||||
|
||||
public boolean hasAnyActive() {
|
||||
return activeModel != null || activePet != null || activeAccessory != null;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return activeModel != null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,659 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A single cosmetic asset: a Blockbench (.bbmodel) bone/cube tree plus its texture(s),
|
||||
* loaded from a user-provided folder (avatar.json + model.bbmodel + textures).
|
||||
*/
|
||||
@Getter
|
||||
public class CosmeticModel {
|
||||
private final String id;
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final String accentColor;
|
||||
private final String folderName;
|
||||
private final CosmeticType type;
|
||||
private final File externalDirectory;
|
||||
@Setter private String source = "Своя";
|
||||
|
||||
private final List<CosmeticBone> rootBones = new ArrayList<>();
|
||||
private final List<CosmeticBone> renderRoots = new ArrayList<>();
|
||||
private boolean customLegsPresent;
|
||||
private boolean bowReplacementAccessory;
|
||||
private boolean swordReplacementAccessory;
|
||||
private boolean hatOnlyAccessory;
|
||||
private boolean wingsOnlyAccessory;
|
||||
private Set<String> visibleBoneUuids;
|
||||
@Setter private Set<String> animationBoneFilter;
|
||||
private final Map<String, CosmeticBone> boneMap = new HashMap<>();
|
||||
private final Map<String, CosmeticBone> boneMapByUuid = new HashMap<>();
|
||||
private final Map<String, CosmeticBbAnimation> bbAnimations = new LinkedHashMap<>();
|
||||
private final List<CosmeticTextureSlot> textureSlots = new ArrayList<>();
|
||||
private int textureWidth = 64;
|
||||
private int textureHeight = 64;
|
||||
private Identifier textureId;
|
||||
private boolean textureLoaded = false;
|
||||
private CosmeticTextureSlot fallbackSlot;
|
||||
|
||||
private CosmeticModel(String id, String name, String description, String accentColor,
|
||||
String folderName, CosmeticType type, File externalDirectory) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.accentColor = accentColor;
|
||||
this.folderName = folderName;
|
||||
this.type = type;
|
||||
this.externalDirectory = externalDirectory;
|
||||
}
|
||||
|
||||
public CosmeticModel withType(CosmeticType newType) {
|
||||
CosmeticModel copy = new CosmeticModel(id, name, description, accentColor, folderName, newType, externalDirectory);
|
||||
copy.rootBones.addAll(rootBones);
|
||||
copy.renderRoots.addAll(renderRoots);
|
||||
copy.boneMap.putAll(boneMap);
|
||||
copy.boneMapByUuid.putAll(boneMapByUuid);
|
||||
copy.bbAnimations.putAll(bbAnimations);
|
||||
copy.textureSlots.addAll(textureSlots);
|
||||
copy.textureWidth = textureWidth;
|
||||
copy.textureHeight = textureHeight;
|
||||
copy.textureId = textureId;
|
||||
copy.textureLoaded = textureLoaded;
|
||||
copy.source = source;
|
||||
copy.postProcess();
|
||||
return copy;
|
||||
}
|
||||
|
||||
public void resetTexture() {
|
||||
textureLoaded = false;
|
||||
textureId = null;
|
||||
for (CosmeticTextureSlot slot : textureSlots) {
|
||||
slot.reset();
|
||||
}
|
||||
}
|
||||
|
||||
public CosmeticTextureSlot getTextureSlot(int index) {
|
||||
if (!textureSlots.isEmpty()) {
|
||||
if (index >= 0 && index < textureSlots.size()) {
|
||||
return textureSlots.get(index);
|
||||
}
|
||||
return textureSlots.get(0);
|
||||
}
|
||||
if (textureId == null) return null;
|
||||
if (fallbackSlot == null) {
|
||||
fallbackSlot = new CosmeticTextureSlot(0, textureId, textureWidth, textureHeight, "fallback");
|
||||
if (textureLoaded) fallbackSlot.markLoaded();
|
||||
}
|
||||
return fallbackSlot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a cosmetic from a user folder: {@code avatar.json} (metadata) plus a
|
||||
* {@code .bbmodel} (geometry/animation) and its texture(s).
|
||||
*/
|
||||
public static CosmeticModel loadExternal(File folder, String sourceTag) {
|
||||
if (folder == null || !folder.isDirectory()) return null;
|
||||
String key = sourceTag + ":" + folder.getName().toLowerCase();
|
||||
return load(folder.getName(), folder, key, null);
|
||||
}
|
||||
|
||||
private static CosmeticModel load(String folderName, File externalDir, String catalogId, CosmeticType forcedType) {
|
||||
try {
|
||||
JsonObject avatar = new JsonObject();
|
||||
File avatarFile = new File(externalDir, "avatar.json");
|
||||
if (avatarFile.exists()) {
|
||||
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(avatarFile), StandardCharsets.UTF_8)) {
|
||||
avatar = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
String name = avatar.has("name") ? avatar.get("name").getAsString() : folderName;
|
||||
String desc = avatar.has("description") ? avatar.get("description").getAsString() : "";
|
||||
String color = avatar.has("color") ? avatar.get("color").getAsString() : "#ffffff";
|
||||
CosmeticType type = forcedType != null ? forcedType : CosmeticType.fromJson(
|
||||
avatar.has("category") ? avatar.get("category").getAsString()
|
||||
: avatar.has("type") ? avatar.get("type").getAsString() : null
|
||||
);
|
||||
|
||||
CosmeticModel model = new CosmeticModel(catalogId, name, desc, color, folderName, type, externalDir);
|
||||
|
||||
InputStream modelStream = openModelStream(externalDir, folderName);
|
||||
if (modelStream == null) {
|
||||
return model;
|
||||
}
|
||||
|
||||
JsonObject bbmodel;
|
||||
try (InputStreamReader reader = new InputStreamReader(modelStream, StandardCharsets.UTF_8)) {
|
||||
bbmodel = JsonParser.parseReader(reader).getAsJsonObject();
|
||||
}
|
||||
|
||||
if (bbmodel.has("resolution")) {
|
||||
JsonObject res = bbmodel.getAsJsonObject("resolution");
|
||||
model.textureWidth = res.get("width").getAsInt();
|
||||
model.textureHeight = res.get("height").getAsInt();
|
||||
}
|
||||
|
||||
model.textureId = Identifier.fromNamespaceAndPath(
|
||||
"lovisual",
|
||||
"dynamic/cosmetic/" + catalogId.replace(':', '_').replace(' ', '_')
|
||||
);
|
||||
model.loadTextureSlots(bbmodel);
|
||||
|
||||
Map<String, JsonObject> elementMap = new HashMap<>();
|
||||
if (bbmodel.has("elements")) {
|
||||
for (JsonElement el : bbmodel.getAsJsonArray("elements")) {
|
||||
JsonObject obj = el.getAsJsonObject();
|
||||
if (obj.has("uuid")) {
|
||||
elementMap.put(obj.get("uuid").getAsString(), obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, JsonObject> groupMap = buildGroupMap(bbmodel);
|
||||
|
||||
if (bbmodel.has("outliner")) {
|
||||
for (JsonElement el : bbmodel.getAsJsonArray("outliner")) {
|
||||
CosmeticBone bone = parseOutliner(el, elementMap, groupMap, model.textureWidth, model.textureHeight);
|
||||
if (bone != null) {
|
||||
model.rootBones.add(bone);
|
||||
model.indexBone(bone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model.bbAnimations.putAll(CosmeticBbAnimation.parseAll(bbmodel));
|
||||
model.postProcess();
|
||||
|
||||
return model;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream openModelStream(File externalDir, String folderName) throws IOException {
|
||||
File model = new File(externalDir, "model.bbmodel");
|
||||
if (!model.exists()) {
|
||||
model = new File(externalDir, folderName + ".bbmodel");
|
||||
}
|
||||
if (model.exists()) return new FileInputStream(model);
|
||||
File[] bbmodels = externalDir.listFiles((dir, name) -> name.endsWith(".bbmodel"));
|
||||
if (bbmodels != null && bbmodels.length > 0) {
|
||||
return new FileInputStream(bbmodels[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadTextureSlots(JsonObject bbmodel) {
|
||||
textureSlots.clear();
|
||||
if (!bbmodel.has("textures")) return;
|
||||
|
||||
String modelKey = id.replace(':', '_');
|
||||
JsonArray textures = bbmodel.getAsJsonArray("textures");
|
||||
for (int i = 0; i < textures.size(); i++) {
|
||||
if (!textures.get(i).isJsonObject()) continue;
|
||||
JsonObject tex = textures.get(i).getAsJsonObject();
|
||||
textureSlots.add(CosmeticTextureSlot.fromJson(i, modelKey, tex));
|
||||
}
|
||||
}
|
||||
|
||||
public void ensureTextureSlotLoaded(CosmeticTextureSlot slot) {
|
||||
if (slot == null || slot.isLoaded()) return;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) return;
|
||||
slot.load(mc, externalDirectory, folderName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads whichever textures this model needs (per-slot bbmodel textures, or a single
|
||||
* fallback {@code texture.png}/{@code avatar.png} in the cosmetic's folder).
|
||||
*/
|
||||
public void loadTexture() {
|
||||
if (textureLoaded) return;
|
||||
try {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.getTextureManager() == null) return;
|
||||
|
||||
boolean anyLoaded = false;
|
||||
for (CosmeticTextureSlot slot : textureSlots) {
|
||||
slot.load(mc, externalDirectory, folderName);
|
||||
if (slot.isLoaded()) anyLoaded = true;
|
||||
}
|
||||
|
||||
if (!anyLoaded && externalDirectory != null) {
|
||||
String[] names = {"outfits/texture.png", "texture.png", folderName + ".png", "avatar.png"};
|
||||
for (String rel : names) {
|
||||
File tex = new File(externalDirectory, rel);
|
||||
if (!tex.exists()) continue;
|
||||
try (InputStream is = new FileInputStream(tex)) {
|
||||
registerTexture(mc, textureId, is);
|
||||
anyLoaded = true;
|
||||
break;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
} else if (!textureSlots.isEmpty() && textureSlots.get(0).isLoaded()) {
|
||||
textureId = textureSlots.get(0).getId();
|
||||
textureWidth = textureSlots.get(0).getWidth();
|
||||
textureHeight = textureSlots.get(0).getHeight();
|
||||
}
|
||||
|
||||
textureLoaded = anyLoaded;
|
||||
if (!anyLoaded) {
|
||||
textureId = null;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private void registerTexture(Minecraft mc, Identifier rawId, InputStream is) throws IOException {
|
||||
NativeImage img = NativeImage.read(is);
|
||||
DynamicTexture tex = new DynamicTexture(() -> "lovisual_cosmetic", img);
|
||||
mc.getTextureManager().register(rawId, tex);
|
||||
textureId = rawId;
|
||||
textureLoaded = true;
|
||||
}
|
||||
|
||||
private void indexBone(CosmeticBone bone) {
|
||||
boneMap.put(bone.getName().toLowerCase(), bone);
|
||||
boneMapByUuid.put(bone.getUuid(), bone);
|
||||
for (CosmeticBone child : bone.getChildren()) indexBone(child);
|
||||
}
|
||||
|
||||
public CosmeticBone getBone(String name) {
|
||||
return boneMap.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
public CosmeticBone getBoneByUuid(String uuid) {
|
||||
return boneMapByUuid.get(uuid);
|
||||
}
|
||||
|
||||
public boolean hasBbAnimations() {
|
||||
return !bbAnimations.isEmpty();
|
||||
}
|
||||
|
||||
public Collection<CosmeticBbAnimation> getBbAnimations() {
|
||||
return bbAnimations.values();
|
||||
}
|
||||
|
||||
public CosmeticBbAnimation findBbAnimation(String... candidates) {
|
||||
for (String candidate : candidates) {
|
||||
if (candidate == null) continue;
|
||||
CosmeticBbAnimation anim = bbAnimations.get(candidate.toLowerCase());
|
||||
if (anim != null) return anim;
|
||||
}
|
||||
for (String candidate : candidates) {
|
||||
if (candidate == null) continue;
|
||||
String lower = candidate.toLowerCase();
|
||||
for (Map.Entry<String, CosmeticBbAnimation> entry : bbAnimations.entrySet()) {
|
||||
if (entry.getKey().contains(lower) || lower.contains(entry.getKey())) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public CosmeticBbAnimation getFirstBbAnimation() {
|
||||
for (String preferred : List.of(
|
||||
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
|
||||
"walking", "yap", "painting", "pose", "pose2"
|
||||
)) {
|
||||
CosmeticBbAnimation anim = findBbAnimation(preferred);
|
||||
if (anim != null) return anim;
|
||||
}
|
||||
if (bbAnimations.isEmpty()) return null;
|
||||
return bbAnimations.values().iterator().next();
|
||||
}
|
||||
|
||||
public List<CosmeticBone> getRenderRoots() {
|
||||
return renderRoots.isEmpty() ? rootBones : renderRoots;
|
||||
}
|
||||
|
||||
public boolean hasCustomLegs() {
|
||||
return customLegsPresent;
|
||||
}
|
||||
|
||||
public boolean isBowReplacementAccessory() {
|
||||
return bowReplacementAccessory;
|
||||
}
|
||||
|
||||
public boolean isSwordReplacementAccessory() {
|
||||
return swordReplacementAccessory;
|
||||
}
|
||||
|
||||
public boolean isBodyOnlyAccessory() {
|
||||
return type == CosmeticType.ACCESSORY && !bowReplacementAccessory && !swordReplacementAccessory;
|
||||
}
|
||||
|
||||
public boolean hasRestPoseAnimation() {
|
||||
return findBbAnimation(
|
||||
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
|
||||
"walking", "walkingback", "flywalk"
|
||||
) != null;
|
||||
}
|
||||
|
||||
public boolean shouldSkipBone(CosmeticBone bone) {
|
||||
if (visibleBoneUuids != null) {
|
||||
return !visibleBoneUuids.contains(bone.getUuid());
|
||||
}
|
||||
String lower = bone.getName().toLowerCase();
|
||||
if (customLegsPresent && lower.contains("vanilla") && (lower.contains("leg") || lower.contains("foot"))) {
|
||||
return true;
|
||||
}
|
||||
if (lower.contains("rightarm_fp") || lower.contains("leftarm_fp")) {
|
||||
return true;
|
||||
}
|
||||
if (hatOnlyAccessory) {
|
||||
return lower.equals("body") || lower.equals("leftarm") || lower.equals("rightarm")
|
||||
|| lower.equals("leftleg") || lower.equals("rightleg") || lower.equals("root");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a standalone pet cosmetic from a single named bone subtree of a parent model
|
||||
* (e.g. turning a model's "head" bone into its own pet cosmetic).
|
||||
*/
|
||||
public static CosmeticModel derivePet(CosmeticModel parent, String boneName, String petFolder, String petName) {
|
||||
if (parent == null) return null;
|
||||
CosmeticBone source = parent.findBoneDeep(boneName);
|
||||
if (source == null) return null;
|
||||
|
||||
CosmeticModel pet = new CosmeticModel(
|
||||
"derived:" + petFolder.replace('/', '_'),
|
||||
petName,
|
||||
"",
|
||||
parent.getAccentColor(),
|
||||
petFolder,
|
||||
CosmeticType.PET,
|
||||
null
|
||||
);
|
||||
pet.source = parent.source;
|
||||
pet.textureWidth = parent.textureWidth;
|
||||
pet.textureHeight = parent.textureHeight;
|
||||
pet.textureId = parent.textureId;
|
||||
pet.textureSlots.addAll(parent.textureSlots);
|
||||
pet.textureLoaded = parent.textureLoaded;
|
||||
|
||||
CosmeticBone root = source.deepClone();
|
||||
pet.rootBones.add(root);
|
||||
pet.renderRoots.add(root);
|
||||
pet.indexBone(root);
|
||||
|
||||
Set<String> uuids = new HashSet<>();
|
||||
pet.collectUuids(root, uuids);
|
||||
pet.animationBoneFilter = uuids;
|
||||
|
||||
for (Map.Entry<String, CosmeticBbAnimation> entry : parent.bbAnimations.entrySet()) {
|
||||
String key = entry.getKey().toLowerCase();
|
||||
if (key.contains("mons") || key.contains("mini")) {
|
||||
pet.bbAnimations.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if (pet.bbAnimations.isEmpty()) {
|
||||
pet.bbAnimations.putAll(parent.bbAnimations);
|
||||
}
|
||||
pet.postProcess();
|
||||
return pet;
|
||||
}
|
||||
|
||||
private void collectUuids(CosmeticBone bone, Set<String> uuids) {
|
||||
uuids.add(bone.getUuid());
|
||||
for (CosmeticBone child : bone.getChildren()) collectUuids(child, uuids);
|
||||
}
|
||||
|
||||
private CosmeticBone findBoneDeep(String name) {
|
||||
String lower = name.toLowerCase();
|
||||
for (CosmeticBone root : rootBones) {
|
||||
CosmeticBone found = findBoneDeep(root, lower);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CosmeticBone findBoneDeep(CosmeticBone bone, String lower) {
|
||||
if (bone.getName().toLowerCase().equals(lower) || bone.getName().toLowerCase().contains(lower)) {
|
||||
return bone;
|
||||
}
|
||||
for (CosmeticBone child : bone.getChildren()) {
|
||||
CosmeticBone found = findBoneDeep(child, lower);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void postProcess() {
|
||||
customLegsPresent = boneMap.keySet().stream().anyMatch(n ->
|
||||
n.contains("leftleg") || n.contains("rightleg") || n.contains("leftpaw") || n.contains("rightpaw"))
|
||||
&& boneMap.keySet().stream().noneMatch(n -> n.equals("leftlegvanilla") || n.equals("rightlegvanilla")
|
||||
|| (n.contains("vanilla") && n.contains("leg")));
|
||||
|
||||
String folder = folderName.toLowerCase();
|
||||
bowReplacementAccessory = type == CosmeticType.ACCESSORY && folder.contains("bow");
|
||||
swordReplacementAccessory = false;
|
||||
hatOnlyAccessory = type == CosmeticType.ACCESSORY
|
||||
&& (folder.contains("witch") || folder.contains("hat")) && !bowReplacementAccessory;
|
||||
wingsOnlyAccessory = type == CosmeticType.ACCESSORY && folder.contains("wing");
|
||||
visibleBoneUuids = null;
|
||||
if (wingsOnlyAccessory) {
|
||||
visibleBoneUuids = buildWingsBoneChainUuids();
|
||||
}
|
||||
|
||||
renderRoots.clear();
|
||||
for (CosmeticBone root : rootBones) {
|
||||
String n = root.getName().toLowerCase();
|
||||
if (type == CosmeticType.MODEL) {
|
||||
if (n.equals("monst3r") || n.equals("skull") || n.endsWith("_fp")) continue;
|
||||
}
|
||||
if (type == CosmeticType.PET) {
|
||||
if (n.equals("arrow") || n.equals("skull")) continue;
|
||||
}
|
||||
renderRoots.add(root);
|
||||
}
|
||||
if (renderRoots.isEmpty()) {
|
||||
renderRoots.addAll(rootBones);
|
||||
}
|
||||
|
||||
applyAccessorySubtreeFilter();
|
||||
}
|
||||
|
||||
private void applyAccessorySubtreeFilter() {
|
||||
if (hatOnlyAccessory) {
|
||||
CosmeticBone head = findBoneDeep("head");
|
||||
CosmeticBone hatBone = findBoneDeep("bone");
|
||||
if (head != null && hatBone != null) {
|
||||
CosmeticBone mount = new CosmeticBone(
|
||||
head.getName(), head.getUuid(),
|
||||
new Vector3f(head.getPivot()), new Vector3f(head.getDefaultRotation()));
|
||||
mount.getChildren().add(hatBone.deepClone());
|
||||
renderRoots.clear();
|
||||
renderRoots.add(mount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> buildWingsBoneChainUuids() {
|
||||
Set<String> keep = new HashSet<>();
|
||||
CosmeticBone wings = findBoneDeep("wings");
|
||||
if (wings == null) return keep;
|
||||
collectBoneTreeUuids(wings, keep);
|
||||
for (CosmeticBone root : rootBones) {
|
||||
markBonePathToUuid(root, wings.getUuid(), keep);
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
private static void collectBoneTreeUuids(CosmeticBone bone, Set<String> uuids) {
|
||||
uuids.add(bone.getUuid());
|
||||
for (CosmeticBone child : bone.getChildren()) {
|
||||
collectBoneTreeUuids(child, uuids);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean markBonePathToUuid(CosmeticBone bone, String targetUuid, Set<String> keep) {
|
||||
keep.add(bone.getUuid());
|
||||
if (bone.getUuid().equals(targetUuid)) {
|
||||
return true;
|
||||
}
|
||||
for (CosmeticBone child : bone.getChildren()) {
|
||||
if (markBonePathToUuid(child, targetUuid, keep)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
keep.remove(bone.getUuid());
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Map<String, JsonObject> buildGroupMap(JsonObject bbmodel) {
|
||||
Map<String, JsonObject> map = new HashMap<>();
|
||||
if (!bbmodel.has("groups")) return map;
|
||||
for (JsonElement el : bbmodel.getAsJsonArray("groups")) {
|
||||
if (!el.isJsonObject()) continue;
|
||||
JsonObject group = el.getAsJsonObject();
|
||||
if (group.has("uuid")) {
|
||||
map.put(group.get("uuid").getAsString(), group);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Vector3f readVec3(JsonObject obj, String key, Vector3f fallback) {
|
||||
if (obj == null || !obj.has(key)) return new Vector3f(fallback);
|
||||
JsonArray arr = obj.getAsJsonArray(key);
|
||||
return new Vector3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
|
||||
}
|
||||
|
||||
private static CosmeticBone parseOutliner(JsonElement element, Map<String, JsonObject> elementMap,
|
||||
Map<String, JsonObject> groupMap, int texW, int texH) {
|
||||
if (element.isJsonPrimitive() || !element.isJsonObject()) return null;
|
||||
JsonObject obj = element.getAsJsonObject();
|
||||
|
||||
String uuid = obj.has("uuid") ? obj.get("uuid").getAsString() : UUID.randomUUID().toString();
|
||||
JsonObject group = groupMap.get(uuid);
|
||||
|
||||
String name = "bone";
|
||||
if (obj.has("name")) {
|
||||
name = obj.get("name").getAsString();
|
||||
} else if (group != null && group.has("name")) {
|
||||
name = group.get("name").getAsString();
|
||||
}
|
||||
|
||||
Vector3f pivot = obj.has("origin")
|
||||
? readVec3(obj, "origin", new Vector3f())
|
||||
: readVec3(group, "origin", new Vector3f());
|
||||
|
||||
Vector3f rotation = obj.has("rotation")
|
||||
? readVec3(obj, "rotation", new Vector3f())
|
||||
: readVec3(group, "rotation", new Vector3f());
|
||||
|
||||
CosmeticBone bone = new CosmeticBone(name, uuid, pivot, rotation);
|
||||
|
||||
if (obj.has("children")) {
|
||||
for (JsonElement child : obj.getAsJsonArray("children")) {
|
||||
if (child.isJsonPrimitive()) {
|
||||
String childUuid = child.getAsString();
|
||||
JsonObject cubeObj = elementMap.get(childUuid);
|
||||
if (cubeObj != null) {
|
||||
CosmeticCube cube = parseCube(cubeObj, texW, texH);
|
||||
if (cube != null) bone.getCubes().add(cube);
|
||||
}
|
||||
} else {
|
||||
CosmeticBone childBone = parseOutliner(child, elementMap, groupMap, texW, texH);
|
||||
if (childBone != null) bone.getChildren().add(childBone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bone;
|
||||
}
|
||||
|
||||
private static CosmeticCube parseCube(JsonObject obj, int texW, int texH) {
|
||||
try {
|
||||
JsonArray fromArr = obj.getAsJsonArray("from");
|
||||
JsonArray toArr = obj.getAsJsonArray("to");
|
||||
Vector3f from = new Vector3f(fromArr.get(0).getAsFloat(), fromArr.get(1).getAsFloat(), fromArr.get(2).getAsFloat());
|
||||
Vector3f to = new Vector3f(toArr.get(0).getAsFloat(), toArr.get(1).getAsFloat(), toArr.get(2).getAsFloat());
|
||||
|
||||
Vector3f rot = new Vector3f(0, 0, 0);
|
||||
if (obj.has("rotation")) {
|
||||
JsonArray r = obj.getAsJsonArray("rotation");
|
||||
rot.set(r.get(0).getAsFloat(), r.get(1).getAsFloat(), r.get(2).getAsFloat());
|
||||
}
|
||||
|
||||
Vector3f origin = new Vector3f(0, 0, 0);
|
||||
if (obj.has("origin")) {
|
||||
JsonArray o = obj.getAsJsonArray("origin");
|
||||
origin.set(o.get(0).getAsFloat(), o.get(1).getAsFloat(), o.get(2).getAsFloat());
|
||||
}
|
||||
|
||||
float inflate = obj.has("inflate") ? obj.get("inflate").getAsFloat() : 0f;
|
||||
|
||||
CosmeticCube.CubeFace[] faces = new CosmeticCube.CubeFace[6];
|
||||
String[] faceNames = {"north", "east", "south", "west", "up", "down"};
|
||||
|
||||
if (obj.has("faces")) {
|
||||
JsonObject facesObj = obj.getAsJsonObject("faces");
|
||||
for (int i = 0; i < faceNames.length; i++) {
|
||||
String fn = faceNames[i];
|
||||
if (!facesObj.has(fn)) continue;
|
||||
JsonObject face = facesObj.getAsJsonObject(fn);
|
||||
if (!face.has("uv") || !face.get("uv").isJsonArray()) continue;
|
||||
JsonArray uv = face.getAsJsonArray("uv");
|
||||
if (uv.size() < 4) continue;
|
||||
int tex = 0;
|
||||
if (face.has("texture") && !face.get("texture").isJsonNull()) {
|
||||
JsonElement texEl = face.get("texture");
|
||||
if (texEl.isJsonPrimitive() && texEl.getAsJsonPrimitive().isNumber()) {
|
||||
tex = texEl.getAsInt();
|
||||
}
|
||||
}
|
||||
float u1 = uv.get(0).getAsFloat();
|
||||
float v1 = uv.get(1).getAsFloat();
|
||||
float u2 = uv.get(2).getAsFloat();
|
||||
float v2 = uv.get(3).getAsFloat();
|
||||
if (u2 < u1) {
|
||||
float t = u1;
|
||||
u1 = u2;
|
||||
u2 = t;
|
||||
}
|
||||
if (v2 < v1) {
|
||||
float t = v1;
|
||||
v1 = v2;
|
||||
v2 = t;
|
||||
}
|
||||
faces[i] = new CosmeticCube.CubeFace(u1, v1, u2, v2, tex);
|
||||
}
|
||||
}
|
||||
|
||||
return new CosmeticCube(from, to, rot, origin, inflate, faces, texW, texH);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import dev.loki.lovisual.render.engine.RenderState;
|
||||
import dev.loki.lovisual.render.engine.math.RenderMath;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.texture.AbstractTexture;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Renders a rest-pose (no bb-animation) {@link CosmeticModel} bone/cube tree attached to a
|
||||
* player, as camera-relative textured quads through the engine's {@link Renderer3D}/{@link MeshBuilder}.
|
||||
*/
|
||||
public final class CosmeticRenderer {
|
||||
private static final float UNITS_PER_BLOCK = 16.0f;
|
||||
|
||||
private CosmeticRenderer() {
|
||||
}
|
||||
|
||||
public static void renderOnPlayer(Renderer3D renderer, Player player, CosmeticType type, float tickDelta) {
|
||||
if (renderer == null || player == null) return;
|
||||
|
||||
CosmeticModel model = CosmeticManager.get().getActive(type);
|
||||
if (model == null) return;
|
||||
|
||||
model.loadTexture();
|
||||
List<CosmeticBone> roots = model.getRenderRoots();
|
||||
if (roots.isEmpty()) return;
|
||||
|
||||
Vec3 pos = RenderMath.getLerpedPos(player, tickDelta);
|
||||
float bodyYaw = Mth.rotLerp(tickDelta, player.yBodyRotO, player.yBodyRot);
|
||||
|
||||
PoseStack matrices = new PoseStack();
|
||||
matrices.translate(pos.x, pos.y, pos.z);
|
||||
CosmeticAttachmentTransforms.apply(matrices, type, model);
|
||||
matrices.mulPose(Axis.YP.rotationDegrees(180f - bodyYaw));
|
||||
|
||||
Map<Integer, List<PendingFace>> byTexture = new HashMap<>();
|
||||
for (CosmeticBone bone : roots) {
|
||||
collectBone(matrices, model, bone, byTexture);
|
||||
}
|
||||
if (byTexture.isEmpty()) return;
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Vec3 cam = RenderState.cameraPos;
|
||||
|
||||
for (Map.Entry<Integer, List<PendingFace>> entry : byTexture.entrySet()) {
|
||||
CosmeticTextureSlot slot = model.getTextureSlot(entry.getKey());
|
||||
if (slot == null) continue;
|
||||
if (!slot.isLoaded()) {
|
||||
model.ensureTextureSlotLoaded(slot);
|
||||
}
|
||||
if (!slot.isLoaded()) continue;
|
||||
|
||||
AbstractTexture texture = mc.getTextureManager().getTexture(slot.getId());
|
||||
if (texture == null || texture.getTextureView() == null || texture.getSampler() == null) continue;
|
||||
|
||||
Renderer3D.BatchBindings bindings = Renderer3D.BatchBindings.none()
|
||||
.withSampler("u_Texture", texture.getTextureView(), texture.getSampler());
|
||||
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_TEXTURED, Renderer3D.DepthMode.MAIN, bindings);
|
||||
if (mesh == null) continue;
|
||||
|
||||
for (PendingFace face : entry.getValue()) {
|
||||
mesh.ensureQuadCapacity();
|
||||
|
||||
int i1 = mesh.vec3(face.xs[0] - cam.x, face.ys[0] - cam.y, face.zs[0] - cam.z)
|
||||
.raw2(face.us[0], face.vs[0]).color(255, 255, 255, 255).next();
|
||||
int i2 = mesh.vec3(face.xs[1] - cam.x, face.ys[1] - cam.y, face.zs[1] - cam.z)
|
||||
.raw2(face.us[1], face.vs[1]).color(255, 255, 255, 255).next();
|
||||
int i3 = mesh.vec3(face.xs[2] - cam.x, face.ys[2] - cam.y, face.zs[2] - cam.z)
|
||||
.raw2(face.us[2], face.vs[2]).color(255, 255, 255, 255).next();
|
||||
int i4 = mesh.vec3(face.xs[3] - cam.x, face.ys[3] - cam.y, face.zs[3] - cam.z)
|
||||
.raw2(face.us[3], face.vs[3]).color(255, 255, 255, 255).next();
|
||||
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectBone(PoseStack matrices, CosmeticModel model, CosmeticBone bone,
|
||||
Map<Integer, List<PendingFace>> out) {
|
||||
if (model.shouldSkipBone(bone)) return;
|
||||
|
||||
matrices.pushPose();
|
||||
matrices.last().pose().mul(bone.buildMatrix());
|
||||
|
||||
for (CosmeticCube cube : bone.getCubes()) {
|
||||
collectCube(matrices, cube, out);
|
||||
}
|
||||
for (CosmeticBone child : bone.getChildren()) {
|
||||
collectBone(matrices, model, child, out);
|
||||
}
|
||||
|
||||
matrices.popPose();
|
||||
}
|
||||
|
||||
private static void collectCube(PoseStack matrices, CosmeticCube cube, Map<Integer, List<PendingFace>> out) {
|
||||
matrices.pushPose();
|
||||
|
||||
Vector3f origin = cube.getOrigin();
|
||||
Vector3f rotation = cube.getRotation();
|
||||
if (rotation.x != 0f || rotation.y != 0f || rotation.z != 0f) {
|
||||
matrices.translate(origin.x / UNITS_PER_BLOCK, origin.y / UNITS_PER_BLOCK, origin.z / UNITS_PER_BLOCK);
|
||||
matrices.mulPose(Axis.XP.rotationDegrees(rotation.x));
|
||||
matrices.mulPose(Axis.YP.rotationDegrees(rotation.y));
|
||||
matrices.mulPose(Axis.ZP.rotationDegrees(rotation.z));
|
||||
matrices.translate(-origin.x / UNITS_PER_BLOCK, -origin.y / UNITS_PER_BLOCK, -origin.z / UNITS_PER_BLOCK);
|
||||
}
|
||||
|
||||
float inflate = cube.getInflate();
|
||||
float x1 = (cube.getFrom().x - inflate) / UNITS_PER_BLOCK;
|
||||
float y1 = (cube.getFrom().y - inflate) / UNITS_PER_BLOCK;
|
||||
float z1 = (cube.getFrom().z - inflate) / UNITS_PER_BLOCK;
|
||||
float x2 = (cube.getTo().x + inflate) / UNITS_PER_BLOCK;
|
||||
float y2 = (cube.getTo().y + inflate) / UNITS_PER_BLOCK;
|
||||
float z2 = (cube.getTo().z + inflate) / UNITS_PER_BLOCK;
|
||||
|
||||
Matrix4f m = new Matrix4f(matrices.last().pose());
|
||||
CosmeticCube.CubeFace[] faces = cube.getFaces();
|
||||
int tw = cube.getTextureWidth();
|
||||
int th = cube.getTextureHeight();
|
||||
|
||||
for (int side = 0; side < 6; side++) {
|
||||
CosmeticCube.CubeFace face = faces[side];
|
||||
if (face == null) continue;
|
||||
addFace(m, face, tw, th, x1, y1, z1, x2, y2, z2, side, out);
|
||||
}
|
||||
|
||||
matrices.popPose();
|
||||
}
|
||||
|
||||
private static void addFace(Matrix4f m, CosmeticCube.CubeFace face, float tw, float th,
|
||||
float x1, float y1, float z1, float x2, float y2, float z2,
|
||||
int side, Map<Integer, List<PendingFace>> out) {
|
||||
PendingFace quad = switch (side) {
|
||||
case 0 -> quad(m,
|
||||
x2, y2, z1, x1, y2, z1, x1, y1, z1, x2, y1, z1);
|
||||
case 1 -> quad(m,
|
||||
x2, y2, z2, x2, y2, z1, x2, y1, z1, x2, y1, z2);
|
||||
case 2 -> quad(m,
|
||||
x1, y2, z2, x2, y2, z2, x2, y1, z2, x1, y1, z2);
|
||||
case 3 -> quad(m,
|
||||
x1, y2, z1, x1, y2, z2, x1, y1, z2, x1, y1, z1);
|
||||
case 4 -> quad(m,
|
||||
x1, y2, z2, x2, y2, z2, x2, y2, z1, x1, y2, z1);
|
||||
case 5 -> quad(m,
|
||||
x1, y1, z1, x2, y1, z1, x2, y1, z2, x1, y1, z2);
|
||||
default -> null;
|
||||
};
|
||||
if (quad == null) return;
|
||||
|
||||
float u1 = face.getU1() / tw;
|
||||
float v1 = face.getV1() / th;
|
||||
float u2 = face.getU2() / tw;
|
||||
float v2 = face.getV2() / th;
|
||||
quad.us[0] = u1;
|
||||
quad.vs[0] = v1;
|
||||
quad.us[1] = u2;
|
||||
quad.vs[1] = v1;
|
||||
quad.us[2] = u2;
|
||||
quad.vs[2] = v2;
|
||||
quad.us[3] = u1;
|
||||
quad.vs[3] = v2;
|
||||
|
||||
out.computeIfAbsent(face.getTextureIndex(), k -> new ArrayList<>()).add(quad);
|
||||
}
|
||||
|
||||
private static PendingFace quad(Matrix4f m,
|
||||
float x1, float y1, float z1,
|
||||
float x2, float y2, float z2,
|
||||
float x3, float y3, float z3,
|
||||
float x4, float y4, float z4) {
|
||||
PendingFace face = new PendingFace();
|
||||
Vector3f p1 = m.transformPosition(new Vector3f(x1, y1, z1));
|
||||
Vector3f p2 = m.transformPosition(new Vector3f(x2, y2, z2));
|
||||
Vector3f p3 = m.transformPosition(new Vector3f(x3, y3, z3));
|
||||
Vector3f p4 = m.transformPosition(new Vector3f(x4, y4, z4));
|
||||
face.xs = new float[]{p1.x, p2.x, p3.x, p4.x};
|
||||
face.ys = new float[]{p1.y, p2.y, p3.y, p4.y};
|
||||
face.zs = new float[]{p1.z, p2.z, p3.z, p4.z};
|
||||
return face;
|
||||
}
|
||||
|
||||
private static final class PendingFace {
|
||||
float[] xs;
|
||||
float[] ys;
|
||||
float[] zs;
|
||||
final float[] us = new float[4];
|
||||
final float[] vs = new float[4];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import dev.loki.lovisual.config.ConfigPaths;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Scans {@code config/lovisual/cosmetics/<type>/<name>} (and the flat root as a
|
||||
* fallback) for user-provided cosmetic folders (avatar.json + .bbmodel) and keeps the
|
||||
* loaded catalog in memory.
|
||||
*/
|
||||
public final class CosmeticRepository {
|
||||
private static final CosmeticRepository INSTANCE = new CosmeticRepository();
|
||||
|
||||
private final Map<String, CosmeticModel> catalog = new LinkedHashMap<>();
|
||||
|
||||
public static CosmeticRepository get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public Path root() {
|
||||
return ConfigPaths.root().resolve("cosmetics");
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
catalog.clear();
|
||||
discoverUserFolder(root());
|
||||
}
|
||||
|
||||
public List<CosmeticModel> all() {
|
||||
return new ArrayList<>(catalog.values());
|
||||
}
|
||||
|
||||
public List<CosmeticModel> byType(CosmeticType type) {
|
||||
return catalog.values().stream().filter(m -> m.getType() == type).toList();
|
||||
}
|
||||
|
||||
public CosmeticModel get(String id) {
|
||||
return catalog.get(id);
|
||||
}
|
||||
|
||||
private void register(CosmeticModel model) {
|
||||
if (model != null && !catalog.containsKey(model.getId())) {
|
||||
catalog.put(model.getId(), model);
|
||||
}
|
||||
}
|
||||
|
||||
private void discoverUserFolder(Path root) {
|
||||
if (!Files.isDirectory(root)) {
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
for (CosmeticType type : CosmeticType.values()) {
|
||||
Files.createDirectories(root.resolve(type.getId()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (CosmeticType type : CosmeticType.values()) {
|
||||
Path typeDir = root.resolve(type.getId());
|
||||
scanDirectory(typeDir, type);
|
||||
}
|
||||
scanDirectory(root, null);
|
||||
}
|
||||
|
||||
private void scanDirectory(Path dir, CosmeticType forcedType) {
|
||||
if (!Files.isDirectory(dir)) return;
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
stream.filter(Files::isDirectory).forEach(sub -> {
|
||||
File folder = sub.toFile();
|
||||
if (new File(folder, "avatar.json").exists() || hasBbmodel(folder)) {
|
||||
CosmeticModel model = CosmeticModel.loadExternal(folder, "user");
|
||||
if (model != null) {
|
||||
if (forcedType != null && model.getType() == CosmeticType.MODEL) {
|
||||
model = model.withType(forcedType);
|
||||
}
|
||||
register(model);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasBbmodel(File folder) {
|
||||
return new File(folder, "model.bbmodel").exists()
|
||||
|| new File(folder, folder.getName() + ".bbmodel").exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class CosmeticTextureSlot {
|
||||
private final int index;
|
||||
private final Identifier id;
|
||||
private int width;
|
||||
private int height;
|
||||
private final String fileName;
|
||||
|
||||
private boolean loaded;
|
||||
private JsonObject textureJson;
|
||||
|
||||
CosmeticTextureSlot(int index, Identifier id, int width, int height, String fileName) {
|
||||
this.index = index;
|
||||
this.id = id;
|
||||
this.width = Math.max(width, 1);
|
||||
this.height = Math.max(height, 1);
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public Identifier getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
void markLoaded() {
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
static CosmeticTextureSlot fromJson(int index, String modelKey, JsonObject texture) {
|
||||
int w = texture.has("width") ? texture.get("width").getAsInt() : 64;
|
||||
int h = texture.has("height") ? texture.get("height").getAsInt() : 64;
|
||||
String name = texture.has("name") ? texture.get("name").getAsString() : "tex" + index;
|
||||
Identifier id = Identifier.fromNamespaceAndPath(
|
||||
"lovisual",
|
||||
"dynamic/cosmetic/" + modelKey + "/tex/" + index
|
||||
);
|
||||
CosmeticTextureSlot slot = new CosmeticTextureSlot(index, id, w, h, name);
|
||||
slot.textureJson = texture;
|
||||
return slot;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
loaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load this texture slot from, in order: the cosmetic's external folder
|
||||
* (file name, then outfits/<file>, then a generic texture.png fallback), or an
|
||||
* inline base64 "source" embedded in the .bbmodel JSON.
|
||||
*/
|
||||
void load(Minecraft mc, File externalDir, String resourcePath) {
|
||||
if (loaded || mc == null || mc.getTextureManager() == null) return;
|
||||
|
||||
try {
|
||||
if (externalDir != null) {
|
||||
if (tryFile(mc, new File(externalDir, fileName))) return;
|
||||
if (tryFile(mc, new File(externalDir, "outfits/" + fileName))) return;
|
||||
if (tryFile(mc, new File(externalDir, "texture.png"))) return;
|
||||
}
|
||||
|
||||
if (textureJson != null && textureJson.has("source")) {
|
||||
String source = textureJson.get("source").getAsString();
|
||||
if (source.startsWith("data:image") && source.contains(",")) {
|
||||
byte[] bytes = Base64.getDecoder().decode(source.substring(source.indexOf(',') + 1));
|
||||
try (InputStream is = new ByteArrayInputStream(bytes)) {
|
||||
register(mc, is);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryFile(Minecraft mc, File file) throws IOException {
|
||||
if (!file.exists()) return false;
|
||||
try (InputStream is = new FileInputStream(file)) {
|
||||
register(mc, is);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void register(Minecraft mc, InputStream is) throws IOException {
|
||||
NativeImage image = NativeImage.read(is);
|
||||
width = Math.max(image.getWidth(), 1);
|
||||
height = Math.max(image.getHeight(), 1);
|
||||
DynamicTexture texture = new DynamicTexture(() -> "lovisual_cosmetic_" + index, image);
|
||||
mc.getTextureManager().register(id, texture);
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package dev.loki.lovisual.features.cosmetic;
|
||||
|
||||
public enum CosmeticType {
|
||||
MODEL("model", "Модель"),
|
||||
PET("pet", "Питомец"),
|
||||
ACCESSORY("accessory", "Аксессуар");
|
||||
|
||||
private final String id;
|
||||
private final String displayName;
|
||||
|
||||
CosmeticType(String id, String displayName) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public static CosmeticType fromJson(String raw) {
|
||||
if (raw == null || raw.isBlank()) return MODEL;
|
||||
return switch (raw.toLowerCase()) {
|
||||
case "pet", "pets", "питомец", "питомцы" -> PET;
|
||||
case "accessory", "accessories", "item", "items", "аксессуар", "аксессуары" -> ACCESSORY;
|
||||
default -> MODEL;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +63,11 @@ final class ClickGuiInputHandler {
|
|||
static void onEditorMouseButtonScaled(double x, double y, int button, boolean pressed) {
|
||||
updateMouse(x, y, true);
|
||||
if (ClickGuiRenderer.waitingForKey) {
|
||||
if (pressed) ClickGuiKeyBindManager.addPendingMouseButton(button);
|
||||
if (pressed) {
|
||||
ClickGuiKeyBindManager.addPendingMouseButton(button);
|
||||
} else {
|
||||
ClickGuiKeyBindManager.onMouseReleased(button);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ClickGuiRenderer.textEditor != null) {
|
||||
|
|
@ -96,7 +100,11 @@ final class ClickGuiInputHandler {
|
|||
private static void handleMouseButton(int button, boolean pressed) {
|
||||
if (!ClickGuiLifecycle.isInputReady()) return;
|
||||
if (ClickGuiRenderer.waitingForKey) {
|
||||
if (pressed) ClickGuiKeyBindManager.addPendingMouseButton(button);
|
||||
if (pressed) {
|
||||
ClickGuiKeyBindManager.addPendingMouseButton(button);
|
||||
} else {
|
||||
ClickGuiKeyBindManager.onMouseReleased(button);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import dev.loki.lovisual.util.input.KeyUtil;
|
|||
import dev.loki.lovisual.util.wav.ClickGuiSounds;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
final class ClickGuiKeyBindManager {
|
||||
|
|
@ -13,11 +14,18 @@ final class ClickGuiKeyBindManager {
|
|||
private ClickGuiKeyBindManager() {
|
||||
}
|
||||
|
||||
// Keys/buttons currently physically held down while capturing a bind.
|
||||
// A single tap (press+release with nothing else pressed meanwhile) commits
|
||||
// instantly on release; holding it while pressing another key builds a combo,
|
||||
// which commits once every held key in it has been released.
|
||||
private static final Set<Integer> heldKeys = new LinkedHashSet<>();
|
||||
|
||||
static void beginKeyBind(Object owner) {
|
||||
ClickGuiEditorManager.clearInlineText();
|
||||
ClickGuiRenderer.waitingForKey = true;
|
||||
ClickGuiRenderer.keyBindOwner = owner;
|
||||
ClickGuiRenderer.pendingBindKeys.clear();
|
||||
heldKeys.clear();
|
||||
ClickGuiSearch.deactivate();
|
||||
ClickGuiSounds.bindingStart();
|
||||
}
|
||||
|
|
@ -26,17 +34,51 @@ final class ClickGuiKeyBindManager {
|
|||
ClickGuiRenderer.waitingForKey = false;
|
||||
ClickGuiRenderer.keyBindOwner = null;
|
||||
ClickGuiRenderer.pendingBindKeys.clear();
|
||||
heldKeys.clear();
|
||||
}
|
||||
|
||||
/** Cancels bind capture without touching the previously saved value. */
|
||||
static void cancelKeyBind() {
|
||||
if (!ClickGuiRenderer.waitingForKey) return;
|
||||
ClickGuiRenderer.waitingForKey = false;
|
||||
ClickGuiRenderer.keyBindOwner = null;
|
||||
ClickGuiRenderer.pendingBindKeys.clear();
|
||||
heldKeys.clear();
|
||||
ClickGuiSounds.bindingStart();
|
||||
}
|
||||
|
||||
static void addPendingKey(int keyCode, int scancode) {
|
||||
int code = KeyUtil.fromKeyInput(keyCode, scancode);
|
||||
if (code == KeyUtil.NONE) return;
|
||||
ClickGuiRenderer.pendingBindKeys.add(code);
|
||||
heldKeys.add(code);
|
||||
}
|
||||
|
||||
static void addPendingMouseButton(int button) {
|
||||
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
|
||||
ClickGuiRenderer.pendingBindKeys.add(KeyUtil.mouseButtonToCode(button));
|
||||
int code = KeyUtil.mouseButtonToCode(button);
|
||||
ClickGuiRenderer.pendingBindKeys.add(code);
|
||||
heldKeys.add(code);
|
||||
}
|
||||
|
||||
static void onKeyReleased(int keyCode, int scancode) {
|
||||
handleRelease(KeyUtil.fromKeyInput(keyCode, scancode));
|
||||
}
|
||||
|
||||
static void onMouseReleased(int button) {
|
||||
if (button < GLFW.GLFW_MOUSE_BUTTON_1 || button > GLFW.GLFW_MOUSE_BUTTON_LAST) return;
|
||||
handleRelease(KeyUtil.mouseButtonToCode(button));
|
||||
}
|
||||
|
||||
private static void handleRelease(int code) {
|
||||
if (!ClickGuiRenderer.waitingForKey) return;
|
||||
if (code == KeyUtil.NONE) return;
|
||||
if (!heldKeys.remove(code)) return;
|
||||
if (!heldKeys.isEmpty()) return;
|
||||
if (ClickGuiRenderer.pendingBindKeys.isEmpty()) return;
|
||||
|
||||
submitKeySelection(ClickGuiRenderer.pendingBindKeys);
|
||||
finishKeyBind();
|
||||
}
|
||||
|
||||
static void submitKeySelection(Set<Integer> keys) {
|
||||
|
|
|
|||
|
|
@ -122,8 +122,9 @@ final class ClickGuiKeyboardHandler {
|
|||
if (ClickGuiRenderer.waitingForKey) {
|
||||
|
||||
if (key == GLFW.GLFW_KEY_ESCAPE) {
|
||||
ClickGuiKeyBindManager.submitKeySelection((String) null);
|
||||
ClickGuiKeyBindManager.finishKeyBind();
|
||||
// Escape always exits the bind capture immediately without
|
||||
// touching the previously saved bind.
|
||||
ClickGuiKeyBindManager.cancelKeyBind();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +136,11 @@ final class ClickGuiKeyboardHandler {
|
|||
}
|
||||
|
||||
ClickGuiKeyBindManager.addPendingKey(key, scancode);
|
||||
} else if (action == GLFW.GLFW_RELEASE) {
|
||||
// A tap (press+release with no other key held) commits instantly;
|
||||
// holding this key while pressing another builds a combo, which
|
||||
// commits once every held key in it has been released.
|
||||
ClickGuiKeyBindManager.onKeyReleased(key, scancode);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ import java.util.Map;
|
|||
@HudElementRegister(order = 20)
|
||||
public final class BetterTooltips extends AbstractHudElement {
|
||||
|
||||
private static final String[] TOOLTIP_FONT_OPTIONS = {
|
||||
"Onest", "OnestMedium", "OnestLight", "Inter", "InterMedium", "Comfortaa", "Iosevka", "ProFont", "Monsterrat"
|
||||
};
|
||||
public static final BetterTooltips INSTANCE = new BetterTooltips();
|
||||
public static final String GROUP_TOOLTIP_GUI = "vanilla_tooltip_gui";
|
||||
public static final String GROUP_TOOLTIP_ITEMS = "vanilla_tooltip_items";
|
||||
|
|
@ -95,6 +98,9 @@ public final class BetterTooltips extends AbstractHudElement {
|
|||
private final BooleanValue itemTooltipEnabled = bool("item_tooltip", true);
|
||||
private final BooleanValue shulkerPreviewEnabled = bool("shulker_preview", true);
|
||||
private final BooleanValue itemInfoColorize = bool("item_info_colorize", true);
|
||||
private final ModeValue tooltipFont = mode("tooltip_font", "Iosevka", TOOLTIP_FONT_OPTIONS);
|
||||
private final NumberValue<Float> tooltipTextSize =
|
||||
num("tooltip_text_size", 1.0f, 0.5f, 2.0f);
|
||||
private final ItemIdSetValue topIgnore = registerTopIgnore();
|
||||
private final NumberValue<Float> shulkerPreviewSlotSize =
|
||||
visibleWhen(num("shulker_slot_size", 18.0f, 12.0f, 24.0f), this::isShulkerPreviewEnabled);
|
||||
|
|
@ -197,14 +203,14 @@ public final class BetterTooltips extends AbstractHudElement {
|
|||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) return;
|
||||
|
||||
TextRenderer tr = Fonts.renderer("Iosevka", FontInfo.Type.Regular, TextRenderer.get());
|
||||
TextRenderer tr = Fonts.renderer(INSTANCE.tooltipFont.get(), FontInfo.Type.Regular, TextRenderer.get());
|
||||
if (tr == null) return;
|
||||
|
||||
PreviewLayout preview = buildShulkerPreviewLayout();
|
||||
boolean previewOpen = preview != null;
|
||||
List<TooltipLine> finalLines = prepareTooltipLines(lines, previewOpen);
|
||||
|
||||
float textScale = TOOLTIP_TEXT_SCALE * TOOLTIP_SCALE_OVERRIDE;
|
||||
float textScale = TOOLTIP_TEXT_SCALE * TOOLTIP_SCALE_OVERRIDE * INSTANCE.tooltipTextSize.get();
|
||||
tr.begin(textScale, false, false);
|
||||
int maxWidth = 0;
|
||||
for (TooltipLine line : finalLines) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
|
|||
@HudElementRegister(order = 40)
|
||||
public final class CustomBar extends AbstractHudElement {
|
||||
|
||||
static final String COLOR_THEME = "Theme";
|
||||
static final String COLOR_CUSTOM = "Custom";
|
||||
public static final CustomBar INSTANCE = new CustomBar();
|
||||
static final float XP_BAR_WIDTH = 182f;
|
||||
static final double XP_TEXT_SCALE = 0.59;
|
||||
|
|
@ -77,8 +79,6 @@ public final class CustomBar extends AbstractHudElement {
|
|||
static final float LOCATOR_LABEL_CHIP_RADIUS = 3.6f;
|
||||
static final float LOCATOR_LABEL_ACCENT_W = 1.15f;
|
||||
static final int LOCATOR_LABEL_MAX_ROWS = 4;
|
||||
static final String COLOR_THEME = "Theme";
|
||||
static final String COLOR_CUSTOM = "Custom";
|
||||
|
||||
private final NumberValue<Integer> overlayAlpha =
|
||||
new NumberValue<>("overlay_alpha", 255, 30, 255);
|
||||
|
|
|
|||
|
|
@ -39,8 +39,10 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
|
|||
@HudElementRegister(order = 50)
|
||||
public final class CustomHealthBar extends AbstractHudElement {
|
||||
|
||||
public static final CustomHealthBar INSTANCE = new CustomHealthBar();
|
||||
private static final String LAYOUT_ID = "lovisual:api/hud/static/custom_health_bar";
|
||||
private static final String STYLE_TARGET_HUD = "TargetHud";
|
||||
private static final String STYLE_DYNAMIC = "Dynamic";
|
||||
public static final CustomHealthBar INSTANCE = new CustomHealthBar();
|
||||
private static final float HEALTH_BAR_WIDTH = 81f;
|
||||
private static final float HEALTH_BAR_HEIGHT = 9f;
|
||||
private static final float HEALTH_BAR_RADIUS = 5.0f;
|
||||
|
|
@ -50,8 +52,6 @@ public final class CustomHealthBar extends AbstractHudElement {
|
|||
private static final int HEALTH_BAR_X_OFFSET = 0;
|
||||
private static final int HEALTH_BAR_Y_OFFSET = 0;
|
||||
private static final float HEALTH_ANIM_SPEED = 10.0f;
|
||||
private static final String STYLE_TARGET_HUD = "TargetHud";
|
||||
private static final String STYLE_DYNAMIC = "Dynamic";
|
||||
private static float animatedHealth = -1.0f;
|
||||
private static float animatedAbsorb = -1.0f;
|
||||
private static long lastHealthAnimMs = -1L;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
|
|||
@HudElementRegister(order = 30)
|
||||
public final class CustomHotbar extends AbstractHudElement {
|
||||
|
||||
private static final String EFFECT_NONE = "None";
|
||||
private static final String EFFECT_BLUR = "Blur";
|
||||
private static final String EFFECT_GLASS = "Glass";
|
||||
private static final String COLOR_THEME = "Theme";
|
||||
private static final String COLOR_CUSTOM = "Custom";
|
||||
public static final CustomHotbar INSTANCE = new CustomHotbar();
|
||||
private static final int BG_ALPHA = 0xAA;
|
||||
private static final float RADIUS = 6.0f;
|
||||
|
|
@ -49,11 +54,6 @@ public final class CustomHotbar extends AbstractHudElement {
|
|||
private static final float SELECT_SLOT_STEP = SLOT_STEP;
|
||||
private static final float SELECT_ANIMATION_MS = 145.0f;
|
||||
private static final float SELECT_ANIMATION_MIN_PROGRESS = 0.015f;
|
||||
private static final String EFFECT_NONE = "None";
|
||||
private static final String EFFECT_BLUR = "Blur";
|
||||
private static final String EFFECT_GLASS = "Glass";
|
||||
private static final String COLOR_THEME = "Theme";
|
||||
private static final String COLOR_CUSTOM = "Custom";
|
||||
private static int animatedSelectedSlot = -1;
|
||||
private static float selectionStartSlot = 0.0f;
|
||||
private static float selectionTargetSlot = 0.0f;
|
||||
|
|
|
|||
|
|
@ -69,9 +69,8 @@ import static dev.loki.lovisual.features.theme.Theme.theme;
|
|||
|
||||
@HudElementRegister(order = 60)
|
||||
public final class DynamicIsland extends AbstractHudElement {
|
||||
public static final DynamicIsland INSTANCE = new DynamicIsland();
|
||||
|
||||
private static final String LAYOUT_ID = "lovisual:api/hud/static/dynamic_island";
|
||||
public static final DynamicIsland INSTANCE = new DynamicIsland();
|
||||
|
||||
private static final float TOP_Y = 18f;
|
||||
private static final float CLOSED_HEIGHT = 35f;
|
||||
|
|
|
|||
|
|
@ -1,258 +0,0 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.features.module.modules.combat;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.ClipContext;
|
||||
import net.minecraft.world.phys.*;
|
||||
import dev.loki.lovisual.config.common.CommonSettingSchemas;
|
||||
import dev.loki.lovisual.config.values.EnumValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.util.combat.VulcanReachController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
//todo Description
|
||||
@ModuleInfo(
|
||||
id = "reach",
|
||||
displayName = "Reach",
|
||||
category = ModuleCategory.COMBAT
|
||||
)
|
||||
public class Reach extends Module {
|
||||
|
||||
public enum RaycastMode {
|
||||
STRICT,
|
||||
THROUGH_WALLS,
|
||||
NONE
|
||||
}
|
||||
|
||||
public final EnumValue<RaycastMode> raycast =
|
||||
enumCommon(
|
||||
"reachContext",
|
||||
"raycast",
|
||||
CommonSettingSchemas.COMBAT_RAYCAST,
|
||||
RaycastMode.STRICT,
|
||||
RaycastMode.values()
|
||||
);
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private final EnumValue<Mode> mode =
|
||||
enumCommon(
|
||||
"reachMode",
|
||||
"mode",
|
||||
CommonSettingSchemas.ANTICHEAT_MODE,
|
||||
Mode.NORMAL,
|
||||
Mode.values()
|
||||
);
|
||||
|
||||
private final NumberValue<Double> distance =
|
||||
numCommon(
|
||||
"reachDistance",
|
||||
"range",
|
||||
CommonSettingSchemas.COMBAT_RANGE,
|
||||
4.5,
|
||||
3.0,
|
||||
6.0
|
||||
);
|
||||
private final NumberValue<Double> wallDistance =
|
||||
visibleWhen(numCommon(
|
||||
"reachWallDistance",
|
||||
"wall_range",
|
||||
CommonSettingSchemas.COMBAT_WALL_RANGE,
|
||||
3.0,
|
||||
0.0,
|
||||
6.0
|
||||
), () -> raycast.get() == RaycastMode.THROUGH_WALLS);
|
||||
|
||||
private static double boxDistanceSq(AABB box, Vec3 p) {
|
||||
double dx = 0.0;
|
||||
if (p.x < box.minX) dx = box.minX - p.x;
|
||||
else if (p.x > box.maxX) dx = p.x - box.maxX;
|
||||
|
||||
double dy = 0.0;
|
||||
if (p.y < box.minY) dy = box.minY - p.y;
|
||||
else if (p.y > box.maxY) dy = p.y - box.maxY;
|
||||
|
||||
double dz = 0.0;
|
||||
if (p.z < box.minZ) dz = box.minZ - p.z;
|
||||
else if (p.z > box.maxZ) dz = p.z - box.maxZ;
|
||||
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
public EntityHitResult raycastEntities(LocalPlayer player) {
|
||||
if (!isEnabled()) return null;
|
||||
if (player == null || mc.level == null) return null;
|
||||
|
||||
double visibleRange = distance.get();
|
||||
double throughWallsRange = raycast.get() == RaycastMode.THROUGH_WALLS
|
||||
? wallDistance.get()
|
||||
: 0.0;
|
||||
double searchRange = Math.max(visibleRange, throughWallsRange);
|
||||
|
||||
Vec3 start = player.getEyePosition(1.0F);
|
||||
Vec3 direction = player.getViewVector(1.0F).normalize();
|
||||
Vec3 end = start.add(direction.scale(searchRange));
|
||||
|
||||
double blockLimitSq = resolveBlockLimitSq(player, start, end, visibleRange, throughWallsRange);
|
||||
|
||||
AABB search = new AABB(start, end).inflate(1.0);
|
||||
|
||||
List<Entity> candidates = mc.level.getEntities(
|
||||
player,
|
||||
search,
|
||||
e -> e instanceof LivingEntity && e.isAlive()
|
||||
);
|
||||
|
||||
return findBestHit(player, start, end, candidates, blockLimitSq, searchRange);
|
||||
}
|
||||
|
||||
public EntityHitResult raycastEntities(LocalPlayer player, Vec3 start, Vec3 direction) {
|
||||
if (!isEnabled()) return null;
|
||||
if (player == null || mc.level == null) return null;
|
||||
|
||||
double visibleRange = distance.get();
|
||||
double throughWallsRange = raycast.get() == RaycastMode.THROUGH_WALLS
|
||||
? wallDistance.get()
|
||||
: 0.0;
|
||||
double searchRange = Math.max(visibleRange, throughWallsRange);
|
||||
|
||||
Vec3 dir = direction.normalize();
|
||||
Vec3 end = start.add(dir.scale(searchRange));
|
||||
|
||||
double blockLimitSq = resolveBlockLimitSq(player, start, end, visibleRange, throughWallsRange);
|
||||
|
||||
AABB search = new AABB(start, end).inflate(1.0);
|
||||
|
||||
List<Entity> candidates = mc.level.getEntities(
|
||||
player,
|
||||
search,
|
||||
e -> e instanceof LivingEntity && e.isAlive()
|
||||
);
|
||||
|
||||
return findBestHit(player, start, end, candidates, blockLimitSq, searchRange);
|
||||
}
|
||||
|
||||
private EntityHitResult findBestHit(LocalPlayer player,
|
||||
Vec3 start,
|
||||
Vec3 end,
|
||||
List<Entity> candidates,
|
||||
double blockLimitSq,
|
||||
double requestedReach) {
|
||||
EntityHitResult bestHit = null;
|
||||
double bestBoxDistSq = Double.MAX_VALUE;
|
||||
double bestHitDistSq = Double.MAX_VALUE;
|
||||
|
||||
for (Entity e : candidates) {
|
||||
AABB baseBox = e.getBoundingBox();
|
||||
AABB bb = baseBox;
|
||||
|
||||
Optional<Vec3> hit = bb.contains(start)
|
||||
? Optional.of(start)
|
||||
: bb.clip(start, end);
|
||||
|
||||
if (hit.isEmpty()) continue;
|
||||
|
||||
double boxDistSq = boxDistanceSq(baseBox, start);
|
||||
double entityReach = resolveEntityReach(e, requestedReach);
|
||||
double entityReachSq = entityReach * entityReach;
|
||||
if (boxDistSq > entityReachSq) continue;
|
||||
if (boxDistSq > blockLimitSq) continue;
|
||||
|
||||
double hitDistSq = start.distanceToSqr(hit.get());
|
||||
|
||||
if (boxDistSq < bestBoxDistSq
|
||||
|| (boxDistSq == bestBoxDistSq && hitDistSq < bestHitDistSq)) {
|
||||
bestBoxDistSq = boxDistSq;
|
||||
bestHitDistSq = hitDistSq;
|
||||
bestHit = new EntityHitResult(e, hit.get());
|
||||
}
|
||||
}
|
||||
|
||||
return bestHit;
|
||||
}
|
||||
|
||||
private double resolveEntityReach(Entity entity, double requestedReach) {
|
||||
if (mode.get() == Mode.VULCAN_297 && entity instanceof Player) {
|
||||
return VulcanReachController.INSTANCE.clampReach(entity, requestedReach);
|
||||
}
|
||||
return requestedReach;
|
||||
}
|
||||
|
||||
public boolean isVulcan297Mode() {
|
||||
return mode.get() == Mode.VULCAN_297;
|
||||
}
|
||||
|
||||
public double getConfiguredDistance() {
|
||||
return distance.get();
|
||||
}
|
||||
|
||||
private double resolveBlockLimitSq(LocalPlayer player,
|
||||
Vec3 start,
|
||||
Vec3 end,
|
||||
double visibleRange,
|
||||
double throughWallsRange) {
|
||||
RaycastMode mode = raycast.get();
|
||||
|
||||
if (mode == RaycastMode.NONE) {
|
||||
double maxRange = Math.max(visibleRange, throughWallsRange);
|
||||
return maxRange * maxRange;
|
||||
}
|
||||
|
||||
if (mode == RaycastMode.THROUGH_WALLS) {
|
||||
double maxAllowed = Math.max(visibleRange, throughWallsRange);
|
||||
|
||||
HitResult blockHit = mc.level.clip(new ClipContext(
|
||||
start,
|
||||
end,
|
||||
ClipContext.Block.OUTLINE,
|
||||
ClipContext.Fluid.NONE,
|
||||
player
|
||||
));
|
||||
|
||||
if (!(blockHit instanceof BlockHitResult bhr)) {
|
||||
return maxAllowed * maxAllowed;
|
||||
}
|
||||
|
||||
double blockDistSq = bhr.getLocation().distanceToSqr(start);
|
||||
|
||||
if (throughWallsRange <= 0.0) {
|
||||
return blockDistSq;
|
||||
}
|
||||
|
||||
return Math.max(blockDistSq, throughWallsRange * throughWallsRange);
|
||||
}
|
||||
|
||||
HitResult blockHit = mc.level.clip(new ClipContext(
|
||||
start,
|
||||
end,
|
||||
ClipContext.Block.OUTLINE,
|
||||
ClipContext.Fluid.NONE,
|
||||
player
|
||||
));
|
||||
|
||||
if (blockHit instanceof BlockHitResult bhr) {
|
||||
return bhr.getLocation().distanceToSqr(start);
|
||||
}
|
||||
|
||||
return visibleRange * visibleRange;
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
NORMAL,
|
||||
VULCAN_297
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ public class TargetESP extends Module {
|
|||
private static final String SETTING_BLACKHOLE_SIZE = "blackhole_size";
|
||||
private static final String SETTING_BLACKHOLE_SPIN_SPEED = "blackhole_spin_speed";
|
||||
private static final String SETTING_BLACKHOLE_STYLE = "blackhole_style";
|
||||
private static final String SETTING_VORTEX_SPEED = "vortex_speed";
|
||||
private static final Identifier GHOST_PARTICLE_TEXTURE = TextureStorage.FIRE_FLY;
|
||||
private static final Identifier CAPTURE_MARK_TEXTURE = TextureStorage.CAPTURE;
|
||||
private static final Identifier CRYSTAL_BLOOM = TextureStorage.BLOOM;
|
||||
|
|
@ -87,7 +88,7 @@ public class TargetESP extends Module {
|
|||
"targetEspMode",
|
||||
SETTING_MODE,
|
||||
"ghosts",
|
||||
"ghosts", "ring", "capture_mark", "crystals", "blackhole"
|
||||
"ghosts", "ring", "capture_mark", "crystals", "blackhole", "vortex"
|
||||
);
|
||||
private final RGBAColorValue colorValue = color("targetEspColor", SETTING_COLOR, "#FFFF5555");
|
||||
private final ModeValue colorMode = visibleWhen(modeSetting(
|
||||
|
|
@ -130,6 +131,8 @@ public class TargetESP extends Module {
|
|||
visibleWhen(num("targetEspBlackholeSpinSpeed", SETTING_BLACKHOLE_SPIN_SPEED, 1.0f, 0.2f, 4.0f), this::isBlackholeMode);
|
||||
private final ModeValue blackholeStyle =
|
||||
visibleWhen(modeSetting("targetEspBlackholeStyle", SETTING_BLACKHOLE_STYLE, "Single", "Single", "Body"), this::isBlackholeMode);
|
||||
private final NumberValue<Float> vortexSpeed =
|
||||
visibleWhen(num("targetEspVortexSpeed", SETTING_VORTEX_SPEED, 1.2f, 0.2f, 4.0f), this::isVortexMode);
|
||||
private final List<CrystalInstance> crystalList = new ArrayList<>();
|
||||
private LivingEntity target;
|
||||
private LivingEntity renderTarget;
|
||||
|
|
@ -237,6 +240,75 @@ public class TargetESP extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
private static final int VORTEX_HELIX_STEPS = 28;
|
||||
private static final int VORTEX_RING_SEGMENTS = 24;
|
||||
|
||||
private static void renderVortex(Renderer3D renderer,
|
||||
LivingEntity target,
|
||||
float tickDelta,
|
||||
IntFunction<Integer> palette,
|
||||
float spinSpeed) {
|
||||
if (renderer == null || target == null) return;
|
||||
|
||||
Vec3 pos = RenderMath.getLerpedPos(target, tickDelta);
|
||||
float radius = target.getBbWidth() * 0.7f;
|
||||
float height = target.getBbHeight();
|
||||
double time = (GameClock.millis() / 1000.0) * spinSpeed;
|
||||
|
||||
float prevWidth = RenderState.lineWidth;
|
||||
RenderState.lineWidth = 1.4f;
|
||||
MeshBuilder mesh;
|
||||
try {
|
||||
mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_LINES, Renderer3D.DepthMode.NONE);
|
||||
} finally {
|
||||
RenderState.lineWidth = prevWidth;
|
||||
}
|
||||
if (mesh == null) return;
|
||||
|
||||
for (int strand = 0; strand < 2; strand++) {
|
||||
double strandOffset = strand * Math.PI;
|
||||
int prevIndex = -1;
|
||||
for (int i = 0; i <= VORTEX_HELIX_STEPS; i++) {
|
||||
double t = (double) i / VORTEX_HELIX_STEPS;
|
||||
double angle = time * 2.4 + t * Math.PI * 3.0 + strandOffset;
|
||||
double r = radius * (0.85 + 0.15 * Math.sin(t * Math.PI));
|
||||
double x = pos.x + Math.cos(angle) * r;
|
||||
double y = pos.y + t * height;
|
||||
double z = pos.z + Math.sin(angle) * r;
|
||||
int color = applyOpacity(palette.apply((int) (t * 180)), (float) (1.0 - t * 0.35));
|
||||
|
||||
int index = addVertex(mesh, x, y, z, color);
|
||||
if (prevIndex >= 0) {
|
||||
mesh.line(prevIndex, index);
|
||||
}
|
||||
prevIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
float[] ringHeights = {0.05f, height * 0.5f, height * 0.95f};
|
||||
float[] ringSpeeds = {0.4f, -0.55f, 0.35f};
|
||||
for (int ring = 0; ring < ringHeights.length; ring++) {
|
||||
double ringY = pos.y + ringHeights[ring];
|
||||
double ringRotation = time * ringSpeeds[ring];
|
||||
int ringColor = applyOpacity(palette.apply(60), 0.4f);
|
||||
int prevIndex = -1;
|
||||
int firstIndex = -1;
|
||||
for (int i = 0; i <= VORTEX_RING_SEGMENTS; i++) {
|
||||
double angle = ringRotation + (i % VORTEX_RING_SEGMENTS) * 2.0 * Math.PI / VORTEX_RING_SEGMENTS;
|
||||
double x = pos.x + Math.cos(angle) * radius;
|
||||
double z = pos.z + Math.sin(angle) * radius;
|
||||
int index = addVertex(mesh, x, ringY, z, ringColor);
|
||||
if (firstIndex < 0) {
|
||||
firstIndex = index;
|
||||
}
|
||||
if (prevIndex >= 0) {
|
||||
mesh.line(prevIndex, index);
|
||||
}
|
||||
prevIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void renderCaptureMark(Renderer3D renderer,
|
||||
Entity target,
|
||||
float tickDelta,
|
||||
|
|
@ -730,6 +802,8 @@ public class TargetESP extends Module {
|
|||
} else if ("blackhole".equals(mode)) {
|
||||
renderBlackhole(renderer, renderTarget, tickDelta, palette,
|
||||
blackholeSize.get(), blackholeSpinSpeed.get(), targetAlpha, blackholeStyle.get());
|
||||
} else if ("vortex".equals(mode)) {
|
||||
renderVortex(renderer, renderTarget, tickDelta, palette, vortexSpeed.get());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -900,6 +974,10 @@ public class TargetESP extends Module {
|
|||
return "blackhole".equals(espMode.get());
|
||||
}
|
||||
|
||||
private boolean isVortexMode() {
|
||||
return "vortex".equals(espMode.get());
|
||||
}
|
||||
|
||||
private boolean usesSecondaryColor() {
|
||||
return AnimatedRenderColors.usesSecondary(animatedColorMode());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.material.MapColor;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import dev.loki.lovisual.config.values.EnumValue;
|
||||
import dev.loki.lovisual.config.values.ItemIdSetValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.config.values.RGBAColorValue;
|
||||
import dev.loki.lovisual.features.gui.clickgui.settings.TextListSetting;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.render.engine.animation.AnimatedRenderColors;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.util.time.Timer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
//todo Description
|
||||
@ModuleInfo(
|
||||
id = "blockesp",
|
||||
displayName = "BlockESP",
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Подсвечивает выбранные блоки сквозь стены"
|
||||
)
|
||||
public final class BlockESP extends Module {
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private final ItemIdSetValue blockList =
|
||||
itemList("block_list", TextListSetting.PickerMode.BLOCKS);
|
||||
|
||||
private final NumberValue<Integer> range =
|
||||
num("range", 64, 8, 160);
|
||||
|
||||
private final NumberValue<Float> scanInterval =
|
||||
num("scan_interval", 2.0f, 0.5f, 10.0f);
|
||||
|
||||
private final RGBAColorValue fillColor =
|
||||
color("fill_color", "#4000FFC8");
|
||||
|
||||
private final RGBAColorValue fillColor2 =
|
||||
visibleWhen(color("fill_color2", "#40FFAA00"), this::usesSecondaryColor);
|
||||
|
||||
private final EnumValue<ColorMode> colorMode =
|
||||
enumMode("color_mode", ColorMode.PER_BLOCK, ColorMode.values());
|
||||
|
||||
private final NumberValue<Integer> colorSpeed =
|
||||
visibleWhen(num("color_speed", 18, 2, 54), () -> colorMode.get() != ColorMode.PER_BLOCK && colorMode.get() != ColorMode.STATIC);
|
||||
|
||||
private final Timer scanTimer = new Timer();
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "BlockESP-Scanner");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private final AtomicBoolean scanning = new AtomicBoolean(false);
|
||||
private final AtomicBoolean scanInProgress = new AtomicBoolean(false);
|
||||
private volatile List<BlockPos> foundBlocks = new ArrayList<>();
|
||||
|
||||
public BlockESP() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
scanTimer.setMs(0);
|
||||
scanning.set(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
scanning.set(false);
|
||||
foundBlocks = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
if (mc.level == null || mc.player == null) return;
|
||||
|
||||
if (scanTimer.passedS(scanInterval.get())) {
|
||||
scanTimer.reset();
|
||||
requestScan();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.BEFORE_TRANSLUCENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (!isEnabled() || mc.level == null || mc.player == null) return;
|
||||
if (blockList.get().isEmpty()) return;
|
||||
|
||||
List<BlockPos> snapshot = foundBlocks;
|
||||
for (BlockPos pos : snapshot) {
|
||||
BlockState state = mc.level.getBlockState(pos);
|
||||
if (state.isAir()) continue;
|
||||
if (!blockList.get().contains(blockId(state.getBlock()))) continue;
|
||||
|
||||
int argb = resolveColor(state.getBlock(), pos);
|
||||
addFilledBox(renderer, new AABB(pos), argb);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestScan() {
|
||||
if (mc.level == null || mc.player == null) return;
|
||||
if (!scanning.get()) return;
|
||||
if (executor.isShutdown()) return;
|
||||
if (!scanInProgress.compareAndSet(false, true)) return;
|
||||
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
scan();
|
||||
} finally {
|
||||
scanInProgress.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void scan() {
|
||||
if (mc.level == null || mc.player == null) return;
|
||||
|
||||
List<BlockPos> result = new ArrayList<>();
|
||||
BlockPos center = mc.player.blockPosition();
|
||||
int r = range.get();
|
||||
|
||||
int minX = center.getX() - r;
|
||||
int maxX = center.getX() + r;
|
||||
int minY = Math.max(mc.level.getMinY(), center.getY() - r);
|
||||
int maxY = Math.min(mc.level.getMaxY(), center.getY() + r);
|
||||
int minZ = center.getZ() - r;
|
||||
int maxZ = center.getZ() + r;
|
||||
|
||||
int minChunkX = minX >> 4;
|
||||
int maxChunkX = maxX >> 4;
|
||||
int minChunkZ = minZ >> 4;
|
||||
int maxChunkZ = maxZ >> 4;
|
||||
|
||||
for (int cx = minChunkX; cx <= maxChunkX; cx++) {
|
||||
for (int cz = minChunkZ; cz <= maxChunkZ; cz++) {
|
||||
if (!scanning.get()) return;
|
||||
if (!mc.level.hasChunk(cx, cz)) continue;
|
||||
|
||||
int chunkMinX = Math.max(minX, cx << 4);
|
||||
int chunkMaxX = Math.min(maxX, (cx << 4) + 15);
|
||||
int chunkMinZ = Math.max(minZ, cz << 4);
|
||||
int chunkMaxZ = Math.min(maxZ, (cz << 4) + 15);
|
||||
|
||||
for (int x = chunkMinX; x <= chunkMaxX; x++) {
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
for (int z = chunkMinZ; z <= chunkMaxZ; z++) {
|
||||
if (!scanning.get()) return;
|
||||
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
BlockState state = mc.level.getBlockState(pos);
|
||||
if (state.isAir()) continue;
|
||||
|
||||
if (blockList.get().contains(blockId(state.getBlock()))) {
|
||||
result.add(pos.immutable());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foundBlocks = result;
|
||||
}
|
||||
|
||||
private String blockId(Block block) {
|
||||
return BuiltInRegistries.BLOCK.getKey(block).toString();
|
||||
}
|
||||
|
||||
private int resolveColor(Block block, BlockPos pos) {
|
||||
if (colorMode.get() == ColorMode.PER_BLOCK) {
|
||||
MapColor mapColor = block.defaultMapColor();
|
||||
return withAlpha(mapColor.col, alpha(fillColor.getArgb()));
|
||||
}
|
||||
|
||||
return AnimatedRenderColors.resolve(
|
||||
animatedColorMode(),
|
||||
colorSpeed.get(),
|
||||
spatialIndex(pos),
|
||||
fillColor.getArgb(),
|
||||
fillColor2.getArgb(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private AnimatedRenderColors.Mode animatedColorMode() {
|
||||
return switch (colorMode.get()) {
|
||||
case PER_BLOCK, STATIC -> AnimatedRenderColors.Mode.STATIC;
|
||||
case RAINBOW -> AnimatedRenderColors.Mode.RAINBOW;
|
||||
case FADE -> AnimatedRenderColors.Mode.FADE;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean usesSecondaryColor() {
|
||||
return AnimatedRenderColors.usesSecondary(animatedColorMode());
|
||||
}
|
||||
|
||||
private int spatialIndex(BlockPos pos) {
|
||||
return Math.floorMod(pos.getX() * 13 + pos.getY() * 7 + pos.getZ() * 17, 3600);
|
||||
}
|
||||
|
||||
private static void addFilledBox(Renderer3D renderer, AABB box, int argb) {
|
||||
int a = alpha(argb);
|
||||
int r = red(argb);
|
||||
int g = green(argb);
|
||||
int b = blue(argb);
|
||||
|
||||
double minX = box.minX;
|
||||
double minY = box.minY;
|
||||
double minZ = box.minZ;
|
||||
double maxX = box.maxX;
|
||||
double maxY = box.maxY;
|
||||
double maxZ = box.maxZ;
|
||||
|
||||
renderer.quad(minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ, minX, minY, maxZ, r, g, b, a);
|
||||
renderer.quad(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ, r, g, b, a);
|
||||
renderer.quad(minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ, minX, maxY, maxZ, r, g, b, a);
|
||||
renderer.quad(minX, minY, minZ, minX, maxY, minZ, maxX, maxY, minZ, maxX, minY, minZ, r, g, b, a);
|
||||
renderer.quad(maxX, minY, minZ, maxX, maxY, minZ, maxX, maxY, maxZ, maxX, minY, maxZ, r, g, b, a);
|
||||
renderer.quad(minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ, minX, maxY, minZ, r, g, b, a);
|
||||
}
|
||||
|
||||
private static int withAlpha(int argb, int alpha) {
|
||||
return (alpha << 24) | (argb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
private static int red(int argb) {
|
||||
return (argb >>> 16) & 255;
|
||||
}
|
||||
|
||||
private static int green(int argb) {
|
||||
return (argb >>> 8) & 255;
|
||||
}
|
||||
|
||||
private static int blue(int argb) {
|
||||
return argb & 255;
|
||||
}
|
||||
|
||||
private static int alpha(int argb) {
|
||||
return (argb >>> 24) & 255;
|
||||
}
|
||||
|
||||
public enum ColorMode implements EnumValue.IdProvider {
|
||||
PER_BLOCK("per_block"),
|
||||
STATIC("static"),
|
||||
RAINBOW("rainbow"),
|
||||
FADE("fade");
|
||||
|
||||
private final String id;
|
||||
|
||||
ColorMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.features.cosmetic.CosmeticManager;
|
||||
import dev.loki.lovisual.features.cosmetic.CosmeticModel;
|
||||
import dev.loki.lovisual.features.cosmetic.CosmeticRenderer;
|
||||
import dev.loki.lovisual.features.cosmetic.CosmeticRepository;
|
||||
import dev.loki.lovisual.features.cosmetic.CosmeticType;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import net.minecraft.client.Minecraft;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Renders the locally equipped cosmetic (model/pet/accessory) attached to the local player.
|
||||
* Equip picking is a minimal next/prev keybind for now; a proper catalog screen is a follow-up.
|
||||
*/
|
||||
@ModuleInfo(
|
||||
id = "cosmeticdisplay",
|
||||
displayName = "Cosmetics",
|
||||
aliases = {"cosmetics"},
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Отображает надетую косметику (модель/питомец/аксессуар)"
|
||||
)
|
||||
public final class CosmeticDisplay extends Module {
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
{
|
||||
action("Next Cosmetic", "NONE");
|
||||
action("Prev Cosmetic", "NONE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
CosmeticRepository.get().reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
if (isActionPressedOnce("Next Cosmetic")) {
|
||||
cycle(1);
|
||||
} else if (isActionPressedOnce("Prev Cosmetic")) {
|
||||
cycle(-1);
|
||||
}
|
||||
}
|
||||
|
||||
private void cycle(int direction) {
|
||||
List<CosmeticModel> models = CosmeticRepository.get().byType(CosmeticType.MODEL);
|
||||
if (models.isEmpty()) return;
|
||||
|
||||
CosmeticModel current = CosmeticManager.get().getActive(CosmeticType.MODEL);
|
||||
int index = current == null ? -1 : models.indexOf(current);
|
||||
int next = Math.floorMod(index + direction, models.size());
|
||||
|
||||
CosmeticManager.get().setActive(CosmeticType.MODEL, models.get(next));
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.AFTER_POST_PROCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (!isEnabled() || mc.level == null || mc.player == null) return;
|
||||
|
||||
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.MODEL, tickDelta);
|
||||
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.PET, tickDelta);
|
||||
CosmeticRenderer.renderOnPlayer(renderer, mc.player, CosmeticType.ACCESSORY, tickDelta);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.util.time.GameClock;
|
||||
|
||||
import dev.loki.lovisual.features.module.*;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
|
|
@ -18,7 +17,6 @@ import net.minecraft.client.gui.GuiGraphicsExtractor;
|
|||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.protocol.game.ServerboundInteractPacket;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
|
|
@ -37,14 +35,8 @@ import dev.loki.lovisual.config.values.EnumValue;
|
|||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.config.values.RGBAColorValue;
|
||||
import dev.loki.lovisual.config.common.CommonSettingSchemas;
|
||||
import dev.loki.lovisual.events.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.AttackEntityEvent;
|
||||
import dev.loki.lovisual.events.impl.PacketEvent;
|
||||
import dev.loki.lovisual.features.gui.hud.HudRenderUtil;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.impl.Itemizer;
|
||||
import dev.loki.lovisual.features.module.*;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.modules.combat.Reach;
|
||||
import dev.loki.lovisual.features.relations.CategoryService;
|
||||
import dev.loki.lovisual.features.relations.EntityFilters;
|
||||
import dev.loki.lovisual.features.relations.PlayerRelations;
|
||||
|
|
@ -57,10 +49,8 @@ import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
|||
import dev.loki.lovisual.render.engine.text.FontInfo;
|
||||
import dev.loki.lovisual.render.engine.text.Fonts;
|
||||
import dev.loki.lovisual.render.engine.text.TextRenderer;
|
||||
import dev.loki.lovisual.util.combat.VulcanReachController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
//todo Description
|
||||
@ModuleInfo(
|
||||
|
|
@ -79,9 +69,6 @@ public class Crosshair extends Module {
|
|||
private static final float ORBIZ_PROGRESS_SMOOTH_SPEED = 22.0f;
|
||||
private static final float COOLDOWN_LEAD = 0.06f;
|
||||
private static final float RIPTIDE_CROSS_LENGTH_FACTOR = 0.65f;
|
||||
private static final float VULCAN_REACH_FONT_SIZE = 14.0f;
|
||||
private static final int VULCAN_REACH_PRIMARY = 0xF2FFFFFF;
|
||||
private static final int VULCAN_REACH_SECONDARY = 0xD8D7DEE8;
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
private final EnumValue<CrosshairType> crosshairType =
|
||||
enumSetting("crosshairType", "type", CrosshairType.CLASSIC);
|
||||
|
|
@ -199,15 +186,6 @@ public class Crosshair extends Module {
|
|||
num("crosshairOrbizMotionSmoothing", "orbiz_motion_smoothing", 12.0, 1.0, 30.0),
|
||||
() -> isOrbiz() && isOrbizMotionDynamic()
|
||||
);
|
||||
private final EnumValue<VulcanReachDisplay> vulcanReachDisplay =
|
||||
enumSetting("crosshairVulcanReachDisplay", "vulcan_reach_display",
|
||||
VulcanReachDisplay.TIMED, VulcanReachDisplay.values());
|
||||
private final NumberValue<Integer> vulcanReachLookMs =
|
||||
visibleWhen(num("crosshairVulcanReachLookMs", "vulcan_reach_look_ms", 1200, 0, 3000),
|
||||
() -> vulcanReachDisplay.get() == VulcanReachDisplay.TIMED);
|
||||
private final NumberValue<Integer> vulcanReachAttackMs =
|
||||
visibleWhen(num("crosshairVulcanReachAttackMs", "vulcan_reach_attack_ms", 2500, 0, 6000),
|
||||
() -> vulcanReachDisplay.get() == VulcanReachDisplay.TIMED);
|
||||
private float animatedGap;
|
||||
private int animatedColor = DEFAULT_COLOR;
|
||||
private float animatedOrbizProgress = 1.0f;
|
||||
|
|
@ -221,9 +199,6 @@ public class Crosshair extends Module {
|
|||
private boolean lastSwapCandidate;
|
||||
private boolean lastChestWasElytra;
|
||||
private ItemStack lastChestStack = ItemStack.EMPTY;
|
||||
private long lastVulcanReachLookMs;
|
||||
private long lastVulcanReachAttackMs;
|
||||
private Player lastVulcanReachTarget;
|
||||
|
||||
private static int colorDistance(int a, int b) {
|
||||
int dr = Math.abs(((a >>> 16) & 0xFF) - ((b >>> 16) & 0xFF));
|
||||
|
|
@ -322,7 +297,6 @@ public class Crosshair extends Module {
|
|||
renderOrbiz(renderer, centerX, centerY, scale);
|
||||
float infoGapPx = (float) (orbizRadius.get() * scale);
|
||||
float infoLengthPx = (float) ((orbizGlow.get() + orbizThickness.get()) * scale);
|
||||
renderVulcanReachInfo(textRenderer, centerX, centerY, infoGapPx, infoLengthPx);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -344,7 +318,6 @@ public class Crosshair extends Module {
|
|||
double stemH = lengthPx + gapPx;
|
||||
drawSegment(renderer, barX, barY, barW, thicknessPx, color, outline, outlinePx);
|
||||
drawSegment(renderer, stemX, centerY, thicknessPx, stemH, color, outline, outlinePx);
|
||||
renderVulcanReachInfo(textRenderer, centerX, centerY, gapPx, lengthPx);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -361,35 +334,6 @@ public class Crosshair extends Module {
|
|||
drawSegment(renderer, bottomX, bottomY, thicknessPx, lengthPx, color, outline, outlinePx);
|
||||
drawSegment(renderer, leftX, leftY, lengthPx, thicknessPx, color, outline, outlinePx);
|
||||
drawSegment(renderer, rightX, rightY, lengthPx, thicknessPx, color, outline, outlinePx);
|
||||
renderVulcanReachInfo(textRenderer, centerX, centerY, gapPx, lengthPx);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onAttackEntity(AttackEntityEvent event) {
|
||||
if (!isVulcanReachActive()) {
|
||||
return;
|
||||
}
|
||||
if (event.getPlayer() != mc.player || !(event.getTarget() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
lastVulcanReachAttackMs = GameClock.millis();
|
||||
lastVulcanReachTarget = player;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketSendPost(PacketEvent.SendPost event) {
|
||||
if (!isVulcanReachActive() || mc.level == null) {
|
||||
return;
|
||||
}
|
||||
if (!(event.getPacket() instanceof ServerboundInteractPacket packet) || !isAttackPacket(packet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entity target = mc.level.getEntity(packet.entityId());
|
||||
if (target instanceof Player player) {
|
||||
lastVulcanReachAttackMs = GameClock.millis();
|
||||
lastVulcanReachTarget = player;
|
||||
}
|
||||
}
|
||||
|
||||
private float getAttackCooldownProgress() {
|
||||
|
|
@ -555,85 +499,6 @@ public class Crosshair extends Module {
|
|||
return resolveEntityColor(entity);
|
||||
}
|
||||
|
||||
private void renderVulcanReachInfo(TextRenderer fallback, float centerX, float centerY, float gapPx, float lengthPx) {
|
||||
if (!isVulcanReachActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player target = resolveVulcanReachTarget();
|
||||
double requestedReach = resolveVulcanRequestedReach(target);
|
||||
VulcanReachController.HudSnapshot snapshot = target != null && target.isAlive()
|
||||
? VulcanReachController.INSTANCE.hudSnapshot(target, requestedReach)
|
||||
: vulcanReachDisplay.get() == VulcanReachDisplay.ALWAYS
|
||||
? VulcanReachController.INSTANCE.hudSnapshot(requestedReach)
|
||||
: null;
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String reachText = String.format(Locale.ROOT, "%.1f", snapshot.displayReach());
|
||||
String hitsText = Integer.toString(snapshot.longHitsRemaining());
|
||||
|
||||
TextRenderer font = Fonts.renderer("Montserrat", FontInfo.Type.Regular, fallback);
|
||||
float textScale = VULCAN_REACH_FONT_SIZE / 18.0f;
|
||||
float x = centerX + gapPx + lengthPx + 7.0f;
|
||||
float y = centerY - VULCAN_REACH_FONT_SIZE * 0.95f;
|
||||
float lineHeight = VULCAN_REACH_FONT_SIZE * 0.92f;
|
||||
|
||||
font.begin(textScale, false, false);
|
||||
font.render(reachText, x, y, new RenderColor(VULCAN_REACH_PRIMARY), true);
|
||||
font.render(hitsText, x, y + lineHeight, new RenderColor(VULCAN_REACH_SECONDARY), true);
|
||||
font.end();
|
||||
}
|
||||
|
||||
private boolean isVulcanReachActive() {
|
||||
Reach reach = Modules.get(Reach.class);
|
||||
return reach != null && reach.isEnabled() && reach.isVulcan297Mode();
|
||||
}
|
||||
|
||||
private double resolveVulcanRequestedReach(Player target) {
|
||||
Reach reach = Modules.get(Reach.class);
|
||||
if (reach != null && reach.isEnabled() && reach.isVulcan297Mode()) {
|
||||
return reach.getConfiguredDistance();
|
||||
}
|
||||
|
||||
return 3.0;
|
||||
}
|
||||
|
||||
private Player resolveVulcanReachTarget() {
|
||||
long now = GameClock.millis();
|
||||
HitResult hit = mc.hitResult;
|
||||
if (hit instanceof EntityHitResult entityHit
|
||||
&& entityHit.getEntity() instanceof Player player
|
||||
&& player.isAlive()) {
|
||||
lastVulcanReachLookMs = now;
|
||||
lastVulcanReachTarget = player;
|
||||
return player;
|
||||
}
|
||||
|
||||
if (lastVulcanReachTarget != null && !lastVulcanReachTarget.isAlive()) {
|
||||
lastVulcanReachTarget = null;
|
||||
}
|
||||
|
||||
if (vulcanReachDisplay.get() == VulcanReachDisplay.ALWAYS) {
|
||||
return lastVulcanReachTarget;
|
||||
}
|
||||
|
||||
if (lastVulcanReachTarget == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long lookWindow = Math.max(0, vulcanReachLookMs.get());
|
||||
long attackWindow = Math.max(0, vulcanReachAttackMs.get());
|
||||
boolean recentLook = lookWindow > 0 && now - lastVulcanReachLookMs <= lookWindow;
|
||||
boolean recentAttack = attackWindow > 0 && now - lastVulcanReachAttackMs <= attackWindow;
|
||||
return recentLook || recentAttack ? lastVulcanReachTarget : null;
|
||||
}
|
||||
|
||||
private boolean isAttackPacket(ServerboundInteractPacket packet) {
|
||||
return packet.hand() == null && packet.location() == null;
|
||||
}
|
||||
|
||||
private int smoothCrosshairColor(int targetColor) {
|
||||
if (!colorInitialized) {
|
||||
animatedColor = targetColor;
|
||||
|
|
@ -780,22 +645,6 @@ public class Crosshair extends Module {
|
|||
renderer.quad(x, y, w, h, color);
|
||||
}
|
||||
|
||||
public enum VulcanReachDisplay implements EnumValue.IdProvider {
|
||||
TIMED("timed"),
|
||||
ALWAYS("always");
|
||||
|
||||
private final String id;
|
||||
|
||||
VulcanReachDisplay(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
private enum CrosshairType implements EnumValue.AliasProvider {
|
||||
CLASSIC,
|
||||
T_CLASSIC,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Portions of this file are based on, adapted from, or implemented
|
||||
* with reference to a "Hold My Items" mixin distributed for another
|
||||
* Minecraft client. The original attribution was not preserved.
|
||||
* This implementation has since been substantially modified for LoVisual.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.config.values.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import net.minecraft.client.Minecraft;
|
||||
|
||||
/**
|
||||
* Smooths and adds a subtle hand-held sway to first-person item rendering, so weapons and tools
|
||||
* settle into place with an ease-out-back curve instead of vanilla's linear equip motion.
|
||||
*/
|
||||
@ModuleInfo(
|
||||
id = "holdmyitems",
|
||||
displayName = "Hold My Items",
|
||||
aliases = {"hmi"},
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Плавная анимация удержания предмета в руке вместо линейного ваниллового equip"
|
||||
)
|
||||
public final class HoldMyItems extends Module {
|
||||
|
||||
public final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
public final NumberValue<Float> swayIntensity = num("sway_intensity", 1.0f, 0.0f, 2.0f);
|
||||
public final NumberValue<Float> easeStrength = num("ease_strength", 1.6f, 0.5f, 3.5f);
|
||||
public final BooleanValue offhandToo = bool("offhand", true);
|
||||
|
||||
/**
|
||||
* Ease-out-back: overshoots slightly past 1.0 before settling, giving the equip motion weight.
|
||||
* Ported from the original "Hold My Items" easing curve, generalized with a configurable strength.
|
||||
*/
|
||||
public float easeOutBack(float x) {
|
||||
float c1 = easeStrength.get();
|
||||
float c3 = c1 + 1.0f;
|
||||
float t = x - 1.0f;
|
||||
return 1.0f + c3 * t * t * t + c1 * t * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reshapes the raw [0,1] equip progress with the ease-out-back curve, then blends by intensity
|
||||
* so a intensity of 0 falls back to vanilla's linear motion.
|
||||
*/
|
||||
public float shapeEquipProgress(float linear) {
|
||||
float eased = easeOutBack(linear);
|
||||
float amount = swayIntensity.get();
|
||||
return linear + (eased - linear) * amount;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,14 @@
|
|||
|
||||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.config.values.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.config.values.RGBAColorValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.features.relations.PlayerRelations;
|
||||
import dev.loki.lovisual.render.engine.RenderState;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
|
|
@ -35,6 +37,10 @@ public final class InvisEsp extends Module {
|
|||
|
||||
private final RGBAColorValue outlineColor =
|
||||
color("invisesp_color", "color", "#FF00E5FF");
|
||||
private final BooleanValue highlightFriends =
|
||||
bool("invisesp_highlight_friends", "highlight_friends", true);
|
||||
private final RGBAColorValue friendColor =
|
||||
visibleWhen(color("invisesp_friend_color", "friend_color", "#FF00FF00"), highlightFriends::get);
|
||||
private final NumberValue<Float> lineWidth =
|
||||
num("invisesp_line_width", "line_width", 1.5f, 0.5f, 6.0f);
|
||||
|
||||
|
|
@ -60,11 +66,16 @@ public final class InvisEsp extends Module {
|
|||
RenderState.lineWidth = previousLineWidth;
|
||||
}
|
||||
|
||||
int color = outlineColor.getArgb();
|
||||
int defaultColor = outlineColor.getArgb();
|
||||
boolean highlightFriendsEnabled = highlightFriends.get();
|
||||
for (Player player : mc.level.players()) {
|
||||
if (player == mc.player) continue;
|
||||
if (!isInvisible(player)) continue;
|
||||
|
||||
int color = highlightFriendsEnabled && PlayerRelations.get().isFriendOrStaff(player.getGameProfile().name())
|
||||
? friendColor.getArgb()
|
||||
: defaultColor;
|
||||
|
||||
AABB box = Esp2DRenderer.resolveWorldBox(player, tickDelta);
|
||||
EspMeshBuilder.addOutlineBox(lines, box, color, color, color, color, color, color, color, color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.config.values.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.events.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.AttackEntityEvent;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import dev.loki.lovisual.util.target.TargetManager;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@ModuleInfo(
|
||||
id = "lightning_target_esp",
|
||||
displayName = "Lightning Target ESP",
|
||||
category = ModuleCategory.VISUALS
|
||||
)
|
||||
public class LightningTargetESP extends Module {
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private final NumberValue<Float> lightningCount = num("lightningCount", "count", 4.0f, 1.0f, 10.0f);
|
||||
private final NumberValue<Float> lightningWidth = num("lightningWidth", "width", 1.0f, 0.1f, 3.0f);
|
||||
private final BooleanValue hurtColor = bool("hurtColor", "hurt_color", true);
|
||||
|
||||
private float appearValue = 0f;
|
||||
private Vec3 lastTargetPos = null;
|
||||
private long lastAttackTime = 0L;
|
||||
private LivingEntity lastAttackedEntity = null;
|
||||
|
||||
private final ArrayList<Bolt> bolts = new ArrayList<>();
|
||||
private long lastBoltSpawn = 0L;
|
||||
|
||||
@EventHandler
|
||||
private void onAttack(AttackEntityEvent event) {
|
||||
if (event.getTarget() instanceof LivingEntity entity) {
|
||||
this.lastAttackTime = System.currentTimeMillis();
|
||||
this.lastAttackedEntity = entity;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (mc.player == null || mc.level == null) return;
|
||||
|
||||
LivingEntity currentTarget = TargetManager.getTarget();
|
||||
boolean hasTarget = currentTarget != null && currentTarget.isAlive();
|
||||
|
||||
LivingEntity activeTarget = hasTarget ? currentTarget : lastAttackedEntity;
|
||||
boolean hasActiveTarget = activeTarget != null && activeTarget.isAlive();
|
||||
|
||||
appearValue = animateTo(appearValue, hasActiveTarget ? 1f : 0f, 0.05f);
|
||||
|
||||
if (hasActiveTarget) {
|
||||
this.lastTargetPos = new Vec3(
|
||||
activeTarget.getX(),
|
||||
activeTarget.getY(),
|
||||
activeTarget.getZ()
|
||||
);
|
||||
}
|
||||
|
||||
if (appearValue <= 0.001f) {
|
||||
if (!hasActiveTarget) {
|
||||
lastTargetPos = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
renderLightning(renderer, tickDelta, hasActiveTarget, activeTarget);
|
||||
}
|
||||
|
||||
private void renderLightning(Renderer3D renderer, float partialTicks, boolean hasActiveTarget, LivingEntity activeTarget) {
|
||||
if (lastTargetPos == null) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
int maxBolts = Math.max(4, Math.round(lightningCount.get()) * 2);
|
||||
long spawnInterval = (long) Math.max(15, 65.0f / (lightningCount.get() / 3.0f));
|
||||
|
||||
if (hasActiveTarget && activeTarget != null && activeTarget.isAlive()) {
|
||||
if (bolts.size() < maxBolts) {
|
||||
if (now - lastBoltSpawn > spawnInterval) {
|
||||
bolts.add(spawnBolt(activeTarget));
|
||||
lastBoltSpawn = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
bolts.removeIf(b -> now - b.spawn > b.life);
|
||||
|
||||
if (bolts.isEmpty()) return;
|
||||
|
||||
Vec3 basePos = lastTargetPos;
|
||||
Vec3 cam = mc.gameRenderer.mainCamera().position();
|
||||
float hurtPC = getHurtPC();
|
||||
int baseColor = 0xFF55FFFF;
|
||||
int redColor = 0xFFFF3C3C;
|
||||
int glowColor = blendColor(baseColor, redColor, hurtPC);
|
||||
int coreColor = blendColor(0xFFFFFFFF, redColor, hurtPC);
|
||||
|
||||
float widthScale = lightningWidth.get();
|
||||
|
||||
renderRibbonLayer(renderer, basePos, cam, bolts,
|
||||
0.045f * widthScale, setAlpha(glowColor, (int) (appearValue * 45)), now, appearValue);
|
||||
renderRibbonLayer(renderer, basePos, cam, bolts,
|
||||
0.018f * widthScale, setAlpha(glowColor, (int) (appearValue * 95)), now, appearValue);
|
||||
renderRibbonLayer(renderer, basePos, cam, bolts,
|
||||
0.006f * widthScale, setAlpha(coreColor, (int) (appearValue * 235)), now, appearValue);
|
||||
}
|
||||
|
||||
private void renderRibbonLayer(Renderer3D renderer, Vec3 base, Vec3 cam,
|
||||
ArrayList<Bolt> bolts, float width, int color, long now, float appearValue) {
|
||||
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED, Renderer3D.DepthMode.NONE);
|
||||
if (mesh == null) return;
|
||||
|
||||
for (Bolt bolt : bolts) {
|
||||
float alpha = boltAlpha(bolt, now) * appearValue;
|
||||
if (alpha <= 0.02f) continue;
|
||||
int a = (color >> 24) & 0xFF;
|
||||
int layerA = (int) (a * alpha);
|
||||
if (layerA <= 0 || bolt.points.size() < 2) continue;
|
||||
int r = (color >> 16) & 0xFF;
|
||||
int g = (color >> 8) & 0xFF;
|
||||
int b = color & 0xFF;
|
||||
appendRibbonPoints(mesh, base, cam, bolt.points, width, r, g, b, layerA);
|
||||
for (List<Vec3> branch : bolt.branches) {
|
||||
appendRibbonPoints(mesh, base, cam, branch, width * 0.6f, r, g, b, (int) (layerA * 0.85f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Bolt spawnBolt(LivingEntity target) {
|
||||
ThreadLocalRandom random = ThreadLocalRandom.current();
|
||||
float radius = target.getBbWidth() * 0.5f;
|
||||
float height = target.getBbHeight();
|
||||
|
||||
double startAngle = random.nextDouble() * Math.PI * 2.0;
|
||||
double startY = 0.15 + random.nextDouble() * Math.max(0.2, height - 0.4);
|
||||
Vec3 start = new Vec3(
|
||||
Math.cos(startAngle) * radius * 0.7,
|
||||
startY,
|
||||
Math.sin(startAngle) * radius * 0.7
|
||||
);
|
||||
|
||||
double theta = random.nextDouble() * Math.PI * 2.0;
|
||||
double phi = Math.toRadians((random.nextDouble() - 0.5) * 150.0);
|
||||
Vec3 dir = new Vec3(
|
||||
Math.cos(phi) * Math.cos(theta),
|
||||
Math.sin(phi) * 0.8 + 0.25,
|
||||
Math.cos(phi) * Math.sin(theta)
|
||||
).normalize();
|
||||
|
||||
double length = 0.35 + random.nextDouble() * 0.45;
|
||||
Vec3 end = start.add(dir.scale(length));
|
||||
|
||||
ArrayList<Vec3> points = new ArrayList<>(List.of(start, end));
|
||||
points = displacePoints(points, 0.09, 3);
|
||||
|
||||
ArrayList<List<Vec3>> branches = new ArrayList<>();
|
||||
int branchCount = 1 + random.nextInt(3);
|
||||
for (int i = 0; i < branchCount; i++) {
|
||||
int anchorIndex = 1 + random.nextInt(Math.max(1, points.size() - 2));
|
||||
Vec3 anchor = points.get(anchorIndex);
|
||||
Vec3 branchDir = randomPerpendicular(dir);
|
||||
if (random.nextBoolean()) {
|
||||
branchDir = branchDir.scale(-1.0);
|
||||
}
|
||||
double branchLength = 0.12 + random.nextDouble() * 0.18;
|
||||
Vec3 branchEnd = anchor.add(branchDir.scale(branchLength));
|
||||
ArrayList<Vec3> branchPoints = new ArrayList<>(List.of(anchor, branchEnd));
|
||||
branches.add(displacePoints(branchPoints, 0.05, 2));
|
||||
}
|
||||
|
||||
return new Bolt(points, branches, 140L + random.nextLong(160L), random.nextFloat() * 6.28f);
|
||||
}
|
||||
|
||||
private ArrayList<Vec3> displacePoints(ArrayList<Vec3> input, double amount, int passes) {
|
||||
ArrayList<Vec3> points = new ArrayList<>(input);
|
||||
double amt = amount;
|
||||
for (int pass = 0; pass < passes; pass++) {
|
||||
ArrayList<Vec3> next = new ArrayList<>(points.size() * 2);
|
||||
for (int i = 0; i < points.size() - 1; i++) {
|
||||
Vec3 a = points.get(i);
|
||||
Vec3 b = points.get(i + 1);
|
||||
next.add(a);
|
||||
Vec3 dir = b.subtract(a);
|
||||
if (dir.lengthSqr() < 1.0E-7) {
|
||||
next.add(a.add(b.scale(0.5)));
|
||||
continue;
|
||||
}
|
||||
Vec3 perp = randomPerpendicular(dir.normalize());
|
||||
double offset = (ThreadLocalRandom.current().nextDouble() - 0.5) * 2.0 * amt;
|
||||
next.add(a.add(b.scale(0.5)).add(perp.scale(offset)));
|
||||
}
|
||||
next.add(points.get(points.size() - 1));
|
||||
points = next;
|
||||
amt *= 0.5;
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private Vec3 randomPerpendicular(Vec3 dir) {
|
||||
Vec3 arbitrary = Math.abs(dir.y) < 0.9 ? new Vec3(0, 1, 0) : new Vec3(1, 0, 0);
|
||||
Vec3 perp1 = dir.cross(arbitrary).normalize();
|
||||
Vec3 perp2 = dir.cross(perp1).normalize();
|
||||
double angle = ThreadLocalRandom.current().nextDouble() * Math.PI * 2.0;
|
||||
return perp1.scale(Math.cos(angle)).add(perp2.scale(Math.sin(angle)));
|
||||
}
|
||||
|
||||
private void appendRibbonPoints(MeshBuilder mesh, Vec3 base, Vec3 cam,
|
||||
List<Vec3> points, float width, int r, int g, int b, int a) {
|
||||
if (a <= 0 || points.size() < 2) return;
|
||||
float half = width * 0.5f;
|
||||
|
||||
for (int i = 0; i < points.size() - 1; i++) {
|
||||
Vec3 p1 = base.add(points.get(i));
|
||||
Vec3 p2 = base.add(points.get(i + 1));
|
||||
Vec3 segDir = p2.subtract(p1);
|
||||
if (segDir.lengthSqr() < 1.0E-8) continue;
|
||||
Vec3 viewDir = p1.add(p2).scale(0.5).subtract(cam);
|
||||
if (viewDir.lengthSqr() < 1.0E-8) continue;
|
||||
Vec3 side = segDir.cross(viewDir);
|
||||
double sideLen = side.length();
|
||||
if (sideLen < 1.0E-8 || !Double.isFinite(sideLen)) continue;
|
||||
side = side.scale(1.0 / sideLen);
|
||||
|
||||
float x1 = (float) (p1.x - cam.x);
|
||||
float y1 = (float) (p1.y - cam.y);
|
||||
float z1 = (float) (p1.z - cam.z);
|
||||
float x2 = (float) (p2.x - cam.x);
|
||||
float y2 = (float) (p2.y - cam.y);
|
||||
float z2 = (float) (p2.z - cam.z);
|
||||
float sx = (float) side.x * half;
|
||||
float sy = (float) side.y * half;
|
||||
float sz = (float) side.z * half;
|
||||
|
||||
int i1 = mesh.vec3(x1 - sx, y1 - sy, z1 - sz).color(r, g, b, a).next();
|
||||
int i2 = mesh.vec3(x1 + sx, y1 + sy, z1 + sz).color(r, g, b, a).next();
|
||||
int i3 = mesh.vec3(x2 + sx, y2 + sy, z2 + sz).color(r, g, b, a).next();
|
||||
int i4 = mesh.vec3(x2 - sx, y2 - sy, z2 - sz).color(r, g, b, a).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
|
||||
private float boltAlpha(Bolt bolt, long now) {
|
||||
float life = Math.max(0.0f, Math.min(1.0f, (now - bolt.spawn) / (float) bolt.life));
|
||||
float fade = life < 0.18f ? life / 0.18f : 1.0f - (life - 0.18f) / 0.82f;
|
||||
float flicker = 0.78f + 0.22f * (float) Math.sin(now * 0.045f + bolt.phase);
|
||||
return Math.max(0.0f, Math.min(1.0f, fade)) * flicker;
|
||||
}
|
||||
|
||||
private float getHurtPC() {
|
||||
if (!hurtColor.get()) return 0f;
|
||||
float fromAttack = 0f;
|
||||
long elapsed = System.currentTimeMillis() - lastAttackTime;
|
||||
if (elapsed < 420L && lastAttackedEntity != null && lastAttackedEntity.isAlive()) {
|
||||
fromAttack = 1.0f - (float) elapsed / 420.0f;
|
||||
}
|
||||
float progress = Math.max(0f, fromAttack);
|
||||
return progress * progress * (3.0f - 2.0f * progress);
|
||||
}
|
||||
|
||||
private int blendColor(int c1, int c2, float factor) {
|
||||
factor = Math.max(0f, Math.min(1f, factor));
|
||||
int r1 = (c1 >> 16) & 0xFF, g1 = (c1 >> 8) & 0xFF, b1 = c1 & 0xFF, a1 = (c1 >> 24) & 0xFF;
|
||||
int r2 = (c2 >> 16) & 0xFF, g2 = (c2 >> 8) & 0xFF, b2 = c2 & 0xFF, a2 = (c2 >> 24) & 0xFF;
|
||||
int r = (int) (r1 + (r2 - r1) * factor);
|
||||
int g = (int) (g1 + (g2 - g1) * factor);
|
||||
int b = (int) (b1 + (b2 - b1) * factor);
|
||||
int a = (int) (a1 + (a2 - a1) * factor);
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
private int setAlpha(int color, int alpha) {
|
||||
alpha = Math.max(0, Math.min(255, alpha));
|
||||
return (alpha << 24) | (color & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
private float animateTo(float current, float target, float delta) {
|
||||
if (current < target) return Math.min(current + delta, target);
|
||||
if (current > target) return Math.max(current - delta, target);
|
||||
return current;
|
||||
}
|
||||
|
||||
private static class Bolt {
|
||||
final List<Vec3> points;
|
||||
final List<List<Vec3>> branches;
|
||||
final long spawn;
|
||||
final long life;
|
||||
final float phase;
|
||||
|
||||
Bolt(List<Vec3> points, List<List<Vec3>> branches, long life, float phase) {
|
||||
this.points = points;
|
||||
this.branches = branches;
|
||||
this.spawn = System.currentTimeMillis();
|
||||
this.life = life;
|
||||
this.phase = phase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,869 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals;
|
||||
|
||||
import dev.loki.lovisual.config.values.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.EnumValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.config.values.RGBAColorValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.features.theme.Theme;
|
||||
import dev.loki.lovisual.render.engine.RenderState;
|
||||
import dev.loki.lovisual.render.engine.core.LoVisualWorldMatrices;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.postprocess.PostProcessContext;
|
||||
import dev.loki.lovisual.render.engine.postprocess.PostProcessManager;
|
||||
import dev.loki.lovisual.render.engine.postprocess.PostProcessPass;
|
||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import dev.loki.lovisual.render.engine.uniform.impl.WetSurfaceUniforms;
|
||||
import com.mojang.blaze3d.textures.GpuTextureView;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Volumetric rain: falling drop streaks, ground splashes with debris droplets, ground mist and lightning flashes.
|
||||
* Wet-ground screen-space reflections are a separate follow-up (see {@link #renderWetSurfaceFrame}).
|
||||
*/
|
||||
@ModuleInfo(
|
||||
id = "rain",
|
||||
displayName = "Rain",
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Реалистичный объёмный дождь с брызгами, туманом и молниями"
|
||||
)
|
||||
public final class Rain extends Module implements PostProcessPass {
|
||||
|
||||
private final Matrix4f wetSurfaceProjection = new Matrix4f();
|
||||
private final Matrix4f wetSurfaceInvView = new Matrix4f();
|
||||
private boolean wetSurfaceDepthSupported = true;
|
||||
|
||||
{
|
||||
PostProcessManager.register(this);
|
||||
}
|
||||
|
||||
private static final double GRAVITY = 22.0;
|
||||
private static final double TERMINAL_VELOCITY = 34.0;
|
||||
private static final int RIPPLE_SEGMENTS = 14;
|
||||
private static final int MAX_DROPS = 4200;
|
||||
private static final int MAX_MIST = 90;
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private final EnumValue<Preset> preset = enumMode("preset", Preset.RAIN, Preset.values());
|
||||
private final NumberValue<Float> density = num("density", 1.0f, 0.1f, 3.0f);
|
||||
private final NumberValue<Float> radius = num("radius", 26.0f, 8.0f, 64.0f);
|
||||
private final NumberValue<Float> altitude = num("altitude", 16.0f, 6.0f, 40.0f);
|
||||
private final NumberValue<Float> dropSize = num("drop_size", 1.0f, 0.3f, 2.5f);
|
||||
private final NumberValue<Float> fallSpeed = num("fall_speed", 1.0f, 0.3f, 2.5f);
|
||||
private final NumberValue<Float> wind = num("wind", 1.0f, 0.0f, 3.0f);
|
||||
private final NumberValue<Float> opacity = num("opacity", 1.0f, 0.1f, 2.0f);
|
||||
|
||||
private final BooleanValue splashes = bool("splashes", true);
|
||||
private final BooleanValue droplets = visibleWhen(bool("splash_droplets", true), splashes::get);
|
||||
private final BooleanValue groundMist = bool("ground_mist", true);
|
||||
private final BooleanValue lightning = bool("lightning", false);
|
||||
private final BooleanValue skyCheck = bool("sky_check", true);
|
||||
private final BooleanValue onlyWhenRaining = bool("only_when_raining", false);
|
||||
|
||||
private final EnumValue<ColorMode> colorMode = enumMode("color_mode", ColorMode.REALISTIC, ColorMode.values());
|
||||
private final RGBAColorValue customColor = visibleWhen(color("color", "#FFBFD8E6"), () -> colorMode.get() == ColorMode.CUSTOM);
|
||||
|
||||
private final BooleanValue wetGround = bool("wet_ground", true);
|
||||
private final NumberValue<Float> wetness = visibleWhen(num("wetness", 1.25f, 0.1f, 2.0f), wetGround::get);
|
||||
private final NumberValue<Float> puddleCoverage = visibleWhen(num("puddle_coverage", 0.90f, 0.05f, 1.0f), wetGround::get);
|
||||
private final NumberValue<Float> reflectionStrength = visibleWhen(num("reflection_strength", 1.55f, 0.1f, 2.0f), wetGround::get);
|
||||
private final NumberValue<Float> reflectionDistance = visibleWhen(num("reflection_distance", 56.0f, 12.0f, 96.0f), wetGround::get);
|
||||
private final EnumValue<ReflectionQuality> reflectionQuality = visibleWhen(enumMode("reflection_quality", ReflectionQuality.BALANCED, ReflectionQuality.values()), wetGround::get);
|
||||
private final BooleanValue puddleRipples = visibleWhen(bool("puddle_ripples", true), wetGround::get);
|
||||
private final NumberValue<Float> rippleStrength = visibleWhen(num("ripple_strength", 0.8f, 0.0f, 1.6f), () -> wetGround.get() && puddleRipples.get());
|
||||
|
||||
private final List<Drop> drops = new ArrayList<>();
|
||||
private final List<Splash> splashList = new ArrayList<>();
|
||||
private final List<Droplet> dropletList = new ArrayList<>();
|
||||
private final List<Mist> mistList = new ArrayList<>();
|
||||
private final Map<Long, Integer> groundCache = new HashMap<>();
|
||||
private final BlockPos.MutableBlockPos scratchPos = new BlockPos.MutableBlockPos();
|
||||
private final Random random = new Random();
|
||||
|
||||
private long lastFrameNanos;
|
||||
private float clock;
|
||||
private float flash;
|
||||
private float nextFlashIn = 9.0f;
|
||||
private int cacheTicks;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
lastFrameNanos = 0L;
|
||||
wetSurfaceDepthSupported = true;
|
||||
clearAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
clearAll();
|
||||
}
|
||||
|
||||
private void clearAll() {
|
||||
drops.clear();
|
||||
splashList.clear();
|
||||
dropletList.clear();
|
||||
mistList.clear();
|
||||
groundCache.clear();
|
||||
flash = 0.0f;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
if (mc.player == null || mc.level == null) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
if (++cacheTicks > 30) {
|
||||
cacheTicks = 0;
|
||||
groundCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private float presetDensity() {
|
||||
return switch (preset.get()) {
|
||||
case DRIZZLE -> 0.45f;
|
||||
case DOWNPOUR -> 1.9f;
|
||||
case STORM -> 2.8f;
|
||||
case RAIN -> 1.0f;
|
||||
};
|
||||
}
|
||||
|
||||
private float presetWind() {
|
||||
return switch (preset.get()) {
|
||||
case DRIZZLE -> 0.5f;
|
||||
case DOWNPOUR -> 1.35f;
|
||||
case STORM -> 2.4f;
|
||||
case RAIN -> 1.0f;
|
||||
};
|
||||
}
|
||||
|
||||
private float presetMass() {
|
||||
return switch (preset.get()) {
|
||||
case DRIZZLE -> 0.55f;
|
||||
case DOWNPOUR -> 1.25f;
|
||||
case STORM -> 1.5f;
|
||||
case RAIN -> 1.0f;
|
||||
};
|
||||
}
|
||||
|
||||
private int targetDropCount() {
|
||||
float r = radius.get();
|
||||
float area = r * r * 0.0155f;
|
||||
int count = (int) (area * density.get() * presetDensity() * 26.0f);
|
||||
return Mth.clamp(count, 40, MAX_DROPS);
|
||||
}
|
||||
|
||||
private int resolveColorArgb() {
|
||||
return switch (colorMode.get()) {
|
||||
case CUSTOM -> customColor.getArgb();
|
||||
case THEME -> Theme.theme().accent();
|
||||
case REALISTIC -> 0xFFC8DCE8;
|
||||
};
|
||||
}
|
||||
|
||||
private float random(float min, float max) {
|
||||
return min + random.nextFloat() * (max - min);
|
||||
}
|
||||
|
||||
private int surfaceY(int x, int z) {
|
||||
long key = (((long) x) << 32) ^ (z & 0xFFFFFFFFL);
|
||||
Integer cached = groundCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
int top;
|
||||
try {
|
||||
top = mc.level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, new BlockPos(x, 0, z)).getY();
|
||||
} catch (Throwable ignored) {
|
||||
top = mc.level.getMinY();
|
||||
}
|
||||
if (groundCache.size() < 20000) groundCache.put(key, top);
|
||||
return top;
|
||||
}
|
||||
|
||||
private boolean isBlocking(double x, double y, double z) {
|
||||
if (mc.level == null) return false;
|
||||
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
|
||||
|
||||
BlockState state = mc.level.getBlockState(scratchPos);
|
||||
if (!state.getFluidState().isEmpty()) return true;
|
||||
if (state.isAir()) return false;
|
||||
try {
|
||||
return !state.getCollisionShape(mc.level, scratchPos).isEmpty();
|
||||
} catch (Throwable ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isWater(double x, double y, double z) {
|
||||
if (mc.level == null) return false;
|
||||
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
|
||||
try {
|
||||
return !mc.level.getBlockState(scratchPos).getFluidState().isEmpty();
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean skyVisible(double x, double y, double z) {
|
||||
if (!skyCheck.get() || mc.level == null) return true;
|
||||
scratchPos.set(Mth.floor(x), Mth.floor(y), Mth.floor(z));
|
||||
try {
|
||||
return mc.level.canSeeSky(scratchPos);
|
||||
} catch (Throwable ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnDrop(Vec3 center, boolean fromTop) {
|
||||
float r = radius.get();
|
||||
double angle = random.nextDouble() * Math.PI * 2.0;
|
||||
double dist = Math.sqrt(random.nextDouble()) * r;
|
||||
|
||||
double x = center.x + Math.cos(angle) * dist;
|
||||
double z = center.z + Math.sin(angle) * dist;
|
||||
double y = fromTop
|
||||
? center.y + altitude.get() * random(0.75f, 1.0f)
|
||||
: center.y + random(-4.0f, altitude.get());
|
||||
|
||||
if (!skyVisible(x, y, z)) return;
|
||||
|
||||
Drop drop = new Drop();
|
||||
drop.x = x;
|
||||
drop.y = y;
|
||||
drop.z = z;
|
||||
drop.mass = random(0.6f, 1.4f) * presetMass();
|
||||
drop.vy = -random(6.0f, 11.0f) * drop.mass;
|
||||
drop.phase = random(0.0f, 62.8f);
|
||||
drop.brightness = random(0.55f, 1.0f);
|
||||
drop.ground = surfaceY(Mth.floor(x), Mth.floor(z));
|
||||
drops.add(drop);
|
||||
}
|
||||
|
||||
private void updateDrops(Vec3 center, float dt) {
|
||||
int target = targetDropCount();
|
||||
|
||||
drops.removeIf(d -> {
|
||||
double dx = d.x - center.x;
|
||||
double dz = d.z - center.z;
|
||||
float r = radius.get() + 6.0f;
|
||||
return d.dead || dx * dx + dz * dz > r * r || d.y < center.y - altitude.get() - 12.0;
|
||||
});
|
||||
|
||||
boolean fill = drops.size() < target / 2;
|
||||
int spawnBudget = fill ? Math.min(target - drops.size(), 900) : Math.min(target - drops.size(), 260);
|
||||
for (int i = 0; i < spawnBudget; i++) {
|
||||
spawnDrop(center, !fill);
|
||||
}
|
||||
|
||||
float speedMul = fallSpeed.get();
|
||||
float windMul = wind.get() * presetWind();
|
||||
|
||||
for (int i = 0, size = drops.size(); i < size; i++) {
|
||||
Drop d = drops.get(i);
|
||||
|
||||
double gust = 0.65 + 0.35 * Math.sin(clock * 0.55 + d.phase * 0.15)
|
||||
+ 0.18 * Math.sin(clock * 1.7 + d.phase);
|
||||
double windX = windMul * 3.1 * gust;
|
||||
double windZ = windMul * 1.7 * Math.sin(clock * 0.31 + d.phase * 0.05) * gust;
|
||||
|
||||
double drag = 1.0 - Math.min(0.92, Math.abs(d.vy) / (TERMINAL_VELOCITY * d.mass));
|
||||
d.vy -= GRAVITY * d.mass * drag * dt;
|
||||
if (d.vy < -TERMINAL_VELOCITY * d.mass) d.vy = -TERMINAL_VELOCITY * d.mass;
|
||||
|
||||
d.vx += (windX - d.vx) * Math.min(1.0, dt * 3.2);
|
||||
d.vz += (windZ - d.vz) * Math.min(1.0, dt * 3.2);
|
||||
|
||||
double nx = d.x + d.vx * dt * speedMul;
|
||||
double ny = d.y + d.vy * dt * speedMul;
|
||||
double nz = d.z + d.vz * dt * speedMul;
|
||||
|
||||
boolean hit = false;
|
||||
if (ny <= d.ground + 2.5) {
|
||||
if (isBlocking(nx, ny, nz)) {
|
||||
hit = true;
|
||||
} else if (ny <= d.ground - 3.0) {
|
||||
d.dead = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hit) {
|
||||
if (splashes.get()) addSplash(nx, ny, nz, d);
|
||||
d.dead = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
d.x = nx;
|
||||
d.y = ny;
|
||||
d.z = nz;
|
||||
}
|
||||
}
|
||||
|
||||
private void addSplash(double x, double y, double z, Drop d) {
|
||||
if (splashList.size() > 700) return;
|
||||
|
||||
double impactY = Math.floor(y) + 1.0005;
|
||||
Splash splash = new Splash();
|
||||
splash.x = x;
|
||||
splash.y = impactY;
|
||||
splash.z = z;
|
||||
splash.life = 0.0f;
|
||||
splash.maxLife = random(0.30f, 0.52f);
|
||||
splash.scale = (float) (0.32 + 0.22 * d.mass);
|
||||
splash.water = isWater(x, y - 0.15, z);
|
||||
splashList.add(splash);
|
||||
|
||||
if (!droplets.get() || dropletList.size() > 1400) return;
|
||||
|
||||
int shards = splash.water ? 4 : 3;
|
||||
for (int i = 0; i < shards; i++) {
|
||||
double a = random.nextDouble() * Math.PI * 2.0;
|
||||
double sp = random(1.1f, 2.6f) * d.mass;
|
||||
Droplet drop = new Droplet();
|
||||
drop.x = x;
|
||||
drop.y = impactY + 0.02;
|
||||
drop.z = z;
|
||||
drop.vx = Math.cos(a) * sp * 0.45 + d.vx * 0.08;
|
||||
drop.vz = Math.sin(a) * sp * 0.45 + d.vz * 0.08;
|
||||
drop.vy = random(1.9f, 4.1f);
|
||||
drop.maxLife = random(0.28f, 0.5f);
|
||||
dropletList.add(drop);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDroplets(float dt) {
|
||||
for (int i = dropletList.size() - 1; i >= 0; i--) {
|
||||
Droplet dr = dropletList.get(i);
|
||||
dr.life += dt;
|
||||
if (dr.life >= dr.maxLife) {
|
||||
dropletList.remove(i);
|
||||
continue;
|
||||
}
|
||||
dr.vy -= GRAVITY * 0.55 * dt;
|
||||
dr.x += dr.vx * dt;
|
||||
dr.y += dr.vy * dt;
|
||||
dr.z += dr.vz * dt;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateSplashes(float dt) {
|
||||
for (int i = splashList.size() - 1; i >= 0; i--) {
|
||||
Splash s = splashList.get(i);
|
||||
s.life += dt;
|
||||
if (s.life >= s.maxLife) splashList.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateMist(Vec3 center, float dt) {
|
||||
if (!groundMist.get()) {
|
||||
mistList.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
int target = (int) Mth.clamp(22 * density.get() * presetDensity(), 6, MAX_MIST);
|
||||
|
||||
mistList.removeIf(m -> {
|
||||
m.life += dt;
|
||||
double dx = m.x - center.x;
|
||||
double dz = m.z - center.z;
|
||||
float r = radius.get();
|
||||
m.x += m.vx * dt;
|
||||
m.z += m.vz * dt;
|
||||
return m.life > m.maxLife || dx * dx + dz * dz > r * r;
|
||||
});
|
||||
|
||||
while (mistList.size() < target) {
|
||||
double angle = random.nextDouble() * Math.PI * 2.0;
|
||||
double dist = Math.sqrt(random.nextDouble()) * radius.get() * 0.85;
|
||||
double x = center.x + Math.cos(angle) * dist;
|
||||
double z = center.z + Math.sin(angle) * dist;
|
||||
int ground = surfaceY(Mth.floor(x), Mth.floor(z));
|
||||
|
||||
Mist m = new Mist();
|
||||
m.x = x;
|
||||
m.z = z;
|
||||
m.y = ground + random(0.05f, 0.9f);
|
||||
m.size = random(2.2f, 5.6f);
|
||||
m.maxLife = random(2.4f, 5.5f);
|
||||
m.life = random(0.0f, 1.0f);
|
||||
m.vx = random(-0.35f, 0.35f) * wind.get();
|
||||
m.vz = random(-0.35f, 0.35f) * wind.get();
|
||||
mistList.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLightning(float dt) {
|
||||
if (!lightning.get()) {
|
||||
flash = 0.0f;
|
||||
return;
|
||||
}
|
||||
flash = Math.max(0.0f, flash - dt * 3.4f);
|
||||
nextFlashIn -= dt;
|
||||
if (nextFlashIn <= 0.0f) {
|
||||
flash = random(0.55f, 1.0f);
|
||||
nextFlashIn = random(6.0f, 16.0f);
|
||||
}
|
||||
}
|
||||
|
||||
private float ambientLight(Camera camera) {
|
||||
try {
|
||||
int light = mc.level.getBrightness(net.minecraft.world.level.LightLayer.BLOCK, camera.blockPosition());
|
||||
return Mth.clamp(light / 15.0f, 0.3f, 1.0f);
|
||||
} catch (Throwable ignored) {
|
||||
return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wet-ground screen-space reflection pass (PostProcessPass) ---
|
||||
|
||||
private int reflectionSteps() {
|
||||
return switch (reflectionQuality.get()) {
|
||||
case HIGH -> 16;
|
||||
case BALANCED -> 10;
|
||||
case PERFORMANCE -> 6;
|
||||
};
|
||||
}
|
||||
|
||||
private float rainAmount() {
|
||||
return Mth.clamp(0.55f + density.get() * presetDensity() * 0.28f, 0.55f, 1.45f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isActive() {
|
||||
return isEnabled()
|
||||
&& wetGround.get()
|
||||
&& wetSurfaceDepthSupported
|
||||
&& mc.player != null
|
||||
&& mc.level != null
|
||||
&& (!onlyWhenRaining.get() || mc.level.isRaining());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPriority() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Phase getPhase() {
|
||||
return Phase.PRE_HAND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean render(GpuTextureView src, GpuTextureView dst, float tickDelta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean render(PostProcessContext context, GpuTextureView src, GpuTextureView dst) {
|
||||
if (!isActive() || context == null || src == null || dst == null) return false;
|
||||
|
||||
GpuTextureView mainDepth = context.mainDepth();
|
||||
boolean hasDepth = mainDepth != null;
|
||||
if (!hasDepth) mainDepth = src;
|
||||
|
||||
Matrix4f projection = LoVisualWorldMatrices.renderProjectionMatrix();
|
||||
if (projection == null) projection = RenderState.worldProjection;
|
||||
wetSurfaceProjection.set(projection);
|
||||
wetSurfaceInvView.set(new Matrix4f().rotation(RenderState.cameraRotation));
|
||||
|
||||
WetSurfaceUniforms.update(
|
||||
wetSurfaceProjection,
|
||||
wetSurfaceInvView,
|
||||
(float) RenderState.cameraPos.x, (float) RenderState.cameraPos.y, (float) RenderState.cameraPos.z,
|
||||
context.width() > 0 ? 1.0f / context.width() : 0.0f,
|
||||
context.height() > 0 ? 1.0f / context.height() : 0.0f,
|
||||
clock,
|
||||
hasDepth,
|
||||
wetness.get(),
|
||||
puddleCoverage.get(),
|
||||
reflectionStrength.get(),
|
||||
puddleRipples.get() ? rippleStrength.get() : 0.0f,
|
||||
reflectionDistance.get(),
|
||||
reflectionSteps(),
|
||||
rainAmount(),
|
||||
puddleRipples.get()
|
||||
);
|
||||
|
||||
try {
|
||||
FullScreenRenderer.ensureInit();
|
||||
FullScreenRenderer.begin("LoVisual WetSurface Pass")
|
||||
.attachment(dst)
|
||||
.pipeline(LoVisualRenderPipelines.WET_SURFACE)
|
||||
.uniform("WetSurface", WetSurfaceUniforms.get())
|
||||
.sampler("u_Texture", src, PostProcessManager.getSampler())
|
||||
.sampler("u_MainDepth", mainDepth, PostProcessManager.getSampler())
|
||||
.end();
|
||||
} catch (Throwable t) {
|
||||
wetSurfaceDepthSupported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.AFTER_POST_PROCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (!isEnabled() || mc.player == null || mc.level == null || mc.gameRenderer == null) return;
|
||||
if (mc.gameRenderer.mainCamera() == null) return;
|
||||
if (onlyWhenRaining.get() && !mc.level.isRaining()) {
|
||||
clearAll();
|
||||
return;
|
||||
}
|
||||
|
||||
Camera camera = mc.gameRenderer.mainCamera();
|
||||
|
||||
long now = System.nanoTime();
|
||||
if (lastFrameNanos == 0L) lastFrameNanos = now;
|
||||
float dt = (float) ((now - lastFrameNanos) / 1_000_000_000.0);
|
||||
lastFrameNanos = now;
|
||||
dt = Mth.clamp(dt, 0.0f, 0.05f);
|
||||
clock += dt;
|
||||
|
||||
Vec3 cam = camera.position();
|
||||
|
||||
updateDrops(cam, dt);
|
||||
updateSplashes(dt);
|
||||
updateDroplets(dt);
|
||||
updateMist(cam, dt);
|
||||
updateLightning(dt);
|
||||
|
||||
if (drops.isEmpty() && splashList.isEmpty() && dropletList.isEmpty() && mistList.isEmpty()) return;
|
||||
|
||||
int colorArgb = resolveColorArgb();
|
||||
float r = ((colorArgb >>> 16) & 0xFF) / 255f;
|
||||
float g = ((colorArgb >>> 8) & 0xFF) / 255f;
|
||||
float b = (colorArgb & 0xFF) / 255f;
|
||||
float[] rgb = {r, g, b};
|
||||
|
||||
float ambient = ambientLight(camera);
|
||||
float brightnessMul = Mth.clamp(ambient + flash * 0.9f, 0.0f, 1.65f);
|
||||
float alphaMul = opacity.get() * brightnessMul;
|
||||
|
||||
Quaternionf camRot = RenderState.cameraRotation;
|
||||
float camYaw = camera.yRot();
|
||||
|
||||
MeshBuilder blendMesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED, Renderer3D.DepthMode.MAIN);
|
||||
if (blendMesh != null) {
|
||||
if (groundMist.get()) drawMist(blendMesh, camRot, rgb, alphaMul);
|
||||
drawStreaks(blendMesh, camYaw, rgb, alphaMul, false);
|
||||
if (splashes.get()) drawRipples(blendMesh, rgb, alphaMul);
|
||||
}
|
||||
|
||||
MeshBuilder additiveMesh = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH, Renderer3D.DepthMode.PRE_DEPTH);
|
||||
if (additiveMesh != null) {
|
||||
drawStreaks(additiveMesh, camYaw, rgb, alphaMul, true);
|
||||
if (splashes.get()) {
|
||||
drawCrowns(additiveMesh, camYaw, rgb, alphaMul);
|
||||
if (droplets.get()) drawDroplets(additiveMesh, camYaw, rgb, alphaMul);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int packColor(float r, float g, float b, float a) {
|
||||
int ai = Mth.clamp(Math.round(a * 255.0f), 0, 255);
|
||||
int ri = Mth.clamp(Math.round(r * 255.0f), 0, 255);
|
||||
int gi = Mth.clamp(Math.round(g * 255.0f), 0, 255);
|
||||
int bi = Mth.clamp(Math.round(b * 255.0f), 0, 255);
|
||||
return (ai << 24) | (ri << 16) | (gi << 8) | bi;
|
||||
}
|
||||
|
||||
private static Vector3f rotated(Quaternionf rot, float x, float y, float z) {
|
||||
return new Vector3f(x, y, z).rotate(rot);
|
||||
}
|
||||
|
||||
private void drawStreaks(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul, boolean highlight) {
|
||||
if (drops.isEmpty()) return;
|
||||
|
||||
float sizeMul = dropSize.get();
|
||||
float r = radius.get();
|
||||
float fadeStart = r * 0.62f;
|
||||
|
||||
for (int i = 0, size = drops.size(); i < size; i++) {
|
||||
Drop d = drops.get(i);
|
||||
|
||||
double speed = Math.sqrt(d.vx * d.vx + d.vy * d.vy + d.vz * d.vz);
|
||||
if (speed < 0.0001) continue;
|
||||
|
||||
float length = (float) Mth.clamp((float) (speed * 0.055 * sizeMul), 0.16f, 2.6f);
|
||||
float width = (float) (0.014 + 0.019 * d.mass) * sizeMul * (highlight ? 0.45f : 1.0f);
|
||||
|
||||
float yawRad = (float) Math.toRadians(camYaw);
|
||||
double rightX = Math.cos(yawRad);
|
||||
double rightZ = Math.sin(yawRad);
|
||||
double horizontal = d.vx * rightX + d.vz * rightZ;
|
||||
float tilt = (float) Math.toDegrees(Math.atan2(horizontal, Math.abs(d.vy) + 0.0001));
|
||||
tilt = Mth.clamp(tilt, -55.0f, 55.0f);
|
||||
|
||||
Quaternionf orient = new Quaternionf()
|
||||
.rotateY((float) Math.toRadians(-camYaw))
|
||||
.rotateZ((float) Math.toRadians(-tilt));
|
||||
|
||||
float half = width * 0.5f;
|
||||
float top = length * 0.5f;
|
||||
float bottom = -length * 0.5f;
|
||||
|
||||
float headAlpha = alphaMul * d.brightness * (highlight ? 0.30f : 0.42f);
|
||||
if (headAlpha <= 0.006f) continue;
|
||||
headAlpha = Math.min(headAlpha, 0.95f);
|
||||
float tailAlpha = 0.0f;
|
||||
float headWidth = half;
|
||||
float tailWidth = half * 0.35f;
|
||||
|
||||
Vector3f p1 = rotated(orient, -tailWidth, top, 0.0f);
|
||||
Vector3f p2 = rotated(orient, tailWidth, top, 0.0f);
|
||||
Vector3f p3 = rotated(orient, headWidth, bottom, 0.0f);
|
||||
Vector3f p4 = rotated(orient, -headWidth, bottom, 0.0f);
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(d.x + p1.x, d.y + p1.y, d.z + p1.z).color(packColorObj(rgb, tailAlpha)).next();
|
||||
int i2 = mesh.vec3(d.x + p2.x, d.y + p2.y, d.z + p2.z).color(packColorObj(rgb, tailAlpha)).next();
|
||||
int i3 = mesh.vec3(d.x + p3.x, d.y + p3.y, d.z + p3.z).color(packColorObj(rgb, headAlpha)).next();
|
||||
int i4 = mesh.vec3(d.x + p4.x, d.y + p4.y, d.z + p4.z).color(packColorObj(rgb, headAlpha)).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawRipples(MeshBuilder mesh, float[] rgb, float alphaMul) {
|
||||
if (splashList.isEmpty()) return;
|
||||
|
||||
for (int i = 0, size = splashList.size(); i < size; i++) {
|
||||
Splash s = splashList.get(i);
|
||||
float progress = Mth.clamp(s.life / s.maxLife, 0.0f, 1.0f);
|
||||
float eased = 1.0f - (1.0f - progress) * (1.0f - progress);
|
||||
|
||||
float outer = s.scale * (0.18f + eased * (s.water ? 1.35f : 0.85f));
|
||||
float inner = outer * (0.55f + 0.35f * eased);
|
||||
float alpha = alphaMul * (1.0f - progress) * (s.water ? 0.5f : 0.38f);
|
||||
if (alpha <= 0.01f) continue;
|
||||
|
||||
for (int seg = 0; seg < RIPPLE_SEGMENTS; seg++) {
|
||||
double a0 = (Math.PI * 2.0 / RIPPLE_SEGMENTS) * seg;
|
||||
double a1 = (Math.PI * 2.0 / RIPPLE_SEGMENTS) * (seg + 1);
|
||||
|
||||
float ix0 = (float) (Math.cos(a0) * inner);
|
||||
float iz0 = (float) (Math.sin(a0) * inner);
|
||||
float ix1 = (float) (Math.cos(a1) * inner);
|
||||
float iz1 = (float) (Math.sin(a1) * inner);
|
||||
float ox0 = (float) (Math.cos(a0) * outer);
|
||||
float oz0 = (float) (Math.sin(a0) * outer);
|
||||
float ox1 = (float) (Math.cos(a1) * outer);
|
||||
float oz1 = (float) (Math.sin(a1) * outer);
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(s.x + ix0, s.y, s.z + iz0).color(packColorObj(rgb, alpha)).next();
|
||||
int i2 = mesh.vec3(s.x + ix1, s.y, s.z + iz1).color(packColorObj(rgb, alpha)).next();
|
||||
int i3 = mesh.vec3(s.x + ox1, s.y, s.z + oz1).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i4 = mesh.vec3(s.x + ox0, s.y, s.z + oz0).color(packColorObj(rgb, 0.0f)).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawCrowns(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul) {
|
||||
if (splashList.isEmpty()) return;
|
||||
|
||||
Quaternionf orient = new Quaternionf().rotateY((float) Math.toRadians(-camYaw));
|
||||
|
||||
for (int i = 0, size = splashList.size(); i < size; i++) {
|
||||
Splash s = splashList.get(i);
|
||||
float progress = Mth.clamp(s.life / s.maxLife, 0.0f, 1.0f);
|
||||
if (progress > 0.55f) continue;
|
||||
|
||||
float local = progress / 0.55f;
|
||||
float height = s.scale * (0.55f * (float) Math.sin(local * Math.PI));
|
||||
float alpha = alphaMul * (1.0f - local) * 0.32f;
|
||||
if (alpha <= 0.01f || height <= 0.005f) continue;
|
||||
|
||||
float base = s.scale * 0.16f;
|
||||
Vector3f p1 = rotated(orient, -base * 0.25f, height, 0.0f);
|
||||
Vector3f p2 = rotated(orient, base * 0.25f, height, 0.0f);
|
||||
Vector3f p3 = rotated(orient, base, 0.0f, 0.0f);
|
||||
Vector3f p4 = rotated(orient, -base, 0.0f, 0.0f);
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(s.x + p1.x, s.y + p1.y, s.z + p1.z).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i2 = mesh.vec3(s.x + p2.x, s.y + p2.y, s.z + p2.z).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i3 = mesh.vec3(s.x + p3.x, s.y + p3.y, s.z + p3.z).color(packColorObj(rgb, alpha)).next();
|
||||
int i4 = mesh.vec3(s.x + p4.x, s.y + p4.y, s.z + p4.z).color(packColorObj(rgb, alpha)).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawDroplets(MeshBuilder mesh, float camYaw, float[] rgb, float alphaMul) {
|
||||
if (dropletList.isEmpty()) return;
|
||||
|
||||
float sizeMul = dropSize.get();
|
||||
float yawRad = (float) Math.toRadians(camYaw);
|
||||
|
||||
for (int i = 0, size = dropletList.size(); i < size; i++) {
|
||||
Droplet d = dropletList.get(i);
|
||||
float progress = Mth.clamp(d.life / d.maxLife, 0.0f, 1.0f);
|
||||
float alpha = alphaMul * (1.0f - progress) * 0.5f;
|
||||
if (alpha <= 0.01f) continue;
|
||||
|
||||
double speed = Math.sqrt(d.vx * d.vx + d.vy * d.vy + d.vz * d.vz);
|
||||
float length = (float) Mth.clamp((float) (speed * 0.03 * sizeMul), 0.04f, 0.4f);
|
||||
float width = 0.016f * sizeMul;
|
||||
|
||||
double horizontal = d.vx * Math.cos(yawRad) + d.vz * Math.sin(yawRad);
|
||||
float tilt = (float) Math.toDegrees(Math.atan2(horizontal, -d.vy + 0.0001));
|
||||
|
||||
Quaternionf orient = new Quaternionf()
|
||||
.rotateY((float) Math.toRadians(-camYaw))
|
||||
.rotateZ((float) Math.toRadians(-tilt));
|
||||
|
||||
Vector3f p1 = rotated(orient, -width * 0.4f, length * 0.5f, 0.0f);
|
||||
Vector3f p2 = rotated(orient, width * 0.4f, length * 0.5f, 0.0f);
|
||||
Vector3f p3 = rotated(orient, width, -length * 0.5f, 0.0f);
|
||||
Vector3f p4 = rotated(orient, -width, -length * 0.5f, 0.0f);
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(d.x + p1.x, d.y + p1.y, d.z + p1.z).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i2 = mesh.vec3(d.x + p2.x, d.y + p2.y, d.z + p2.z).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i3 = mesh.vec3(d.x + p3.x, d.y + p3.y, d.z + p3.z).color(packColorObj(rgb, alpha)).next();
|
||||
int i4 = mesh.vec3(d.x + p4.x, d.y + p4.y, d.z + p4.z).color(packColorObj(rgb, alpha)).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMist(MeshBuilder mesh, Quaternionf camRot, float[] rgb, float alphaMul) {
|
||||
if (mistList.isEmpty()) return;
|
||||
|
||||
for (int i = 0, size = mistList.size(); i < size; i++) {
|
||||
Mist mist = mistList.get(i);
|
||||
float progress = Mth.clamp(mist.life / mist.maxLife, 0.0f, 1.0f);
|
||||
float fade = (float) Math.sin(progress * Math.PI);
|
||||
float alpha = alphaMul * fade * 0.05f;
|
||||
if (alpha <= 0.004f) continue;
|
||||
|
||||
float half = mist.size * 0.5f;
|
||||
float top = mist.size * 0.42f;
|
||||
|
||||
Vector3f right = new Vector3f(1.0f, 0.0f, 0.0f).rotate(camRot);
|
||||
Vector3f up = new Vector3f(0.0f, 1.0f, 0.0f).rotate(camRot);
|
||||
|
||||
double bx = mist.x, by = mist.y, bz = mist.z;
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(bx - right.x * half, by - right.y * half + up.y * top, bz - right.z * half).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i2 = mesh.vec3(bx + right.x * half, by + right.y * half + up.y * top, bz + right.z * half).color(packColorObj(rgb, 0.0f)).next();
|
||||
int i3 = mesh.vec3(bx + right.x * half, by + right.y * half, bz + right.z * half).color(packColorObj(rgb, alpha)).next();
|
||||
int i4 = mesh.vec3(bx - right.x * half, by - right.y * half, bz - right.z * half).color(packColorObj(rgb, alpha)).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
}
|
||||
|
||||
private dev.loki.lovisual.render.engine.color.RenderColor packColorObj(float[] rgb, float a) {
|
||||
return new dev.loki.lovisual.render.engine.color.RenderColor(packColor(rgb[0], rgb[1], rgb[2], a));
|
||||
}
|
||||
|
||||
public enum Preset implements EnumValue.IdProvider {
|
||||
DRIZZLE("drizzle"),
|
||||
RAIN("rain"),
|
||||
DOWNPOUR("downpour"),
|
||||
STORM("storm");
|
||||
|
||||
private final String id;
|
||||
|
||||
Preset(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ColorMode implements EnumValue.IdProvider {
|
||||
REALISTIC("realistic"),
|
||||
THEME("theme"),
|
||||
CUSTOM("custom");
|
||||
|
||||
private final String id;
|
||||
|
||||
ColorMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ReflectionQuality implements EnumValue.IdProvider {
|
||||
PERFORMANCE("performance"),
|
||||
BALANCED("balanced"),
|
||||
HIGH("high");
|
||||
|
||||
private final String id;
|
||||
|
||||
ReflectionQuality(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Drop {
|
||||
double x, y, z;
|
||||
double vx, vy, vz;
|
||||
double mass = 1.0;
|
||||
double ground;
|
||||
float phase;
|
||||
float brightness = 1.0f;
|
||||
boolean dead;
|
||||
}
|
||||
|
||||
private static final class Droplet {
|
||||
double x, y, z;
|
||||
double vx, vy, vz;
|
||||
float life;
|
||||
float maxLife = 0.4f;
|
||||
}
|
||||
|
||||
private static final class Splash {
|
||||
double x, y, z;
|
||||
float life;
|
||||
float maxLife = 0.4f;
|
||||
float scale = 0.4f;
|
||||
boolean water;
|
||||
}
|
||||
|
||||
private static final class Mist {
|
||||
double x, y, z;
|
||||
double vx, vz;
|
||||
float size = 3.0f;
|
||||
float life;
|
||||
float maxLife = 4.0f;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,17 @@ import dev.loki.lovisual.config.values.NumberValue;
|
|||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.render.engine.postprocess.SkyShaderRenderer;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import net.minecraft.client.Minecraft;
|
||||
|
||||
//todo Description
|
||||
@ModuleInfo(id = "shadersky", displayName = "ShaderSky", category = ModuleCategory.VISUALS)
|
||||
public class ShaderSky extends Module {
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private static final String SETTING_MODE = "mode";
|
||||
private static final String SETTING_SPEED = "speed";
|
||||
private static final String SETTING_SCALE = "scale";
|
||||
|
|
@ -23,7 +29,7 @@ public class ShaderSky extends Module {
|
|||
"shaderSkyMode",
|
||||
SETTING_MODE,
|
||||
"Thunder",
|
||||
"Thunder", "Pulsar"
|
||||
"Thunder", "Pulsar", "Sky", "Water", "Caustic"
|
||||
);
|
||||
private final NumberValue<Float> speed =
|
||||
num("shaderSkySpeed", SETTING_SPEED, 1.0f, 0.1f, 5.0f);
|
||||
|
|
@ -75,4 +81,41 @@ public class ShaderSky extends Module {
|
|||
private boolean isThunderMode() {
|
||||
return "Thunder".equals(mode.get());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSkyShaderMode() {
|
||||
String m = mode.get();
|
||||
return "Sky".equals(m) || "Water".equals(m) || "Caustic".equals(m);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.AFTER_POST_PROCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
SkyShaderRenderer.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (!isEnabled() || mc.level == null || mc.player == null) return;
|
||||
|
||||
if (isSkyShaderMode()) {
|
||||
SkyShaderRenderer.Mode skyMode = switch (mode.get()) {
|
||||
case "Water" -> SkyShaderRenderer.Mode.WATER;
|
||||
case "Caustic" -> SkyShaderRenderer.Mode.CAUSTIC;
|
||||
default -> SkyShaderRenderer.Mode.SKY;
|
||||
};
|
||||
SkyShaderRenderer.render(
|
||||
mc,
|
||||
skyMode,
|
||||
speed.get(),
|
||||
scale.get(),
|
||||
intensity.get(),
|
||||
alpha.get(),
|
||||
0xFF6FA8DC & 0x00FFFFFF
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
import dev.loki.lovisual.config.values.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.EnumValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.config.values.RGBAColorValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.WorldPhase;
|
||||
import dev.loki.lovisual.features.theme.Theme;
|
||||
import dev.loki.lovisual.render.engine.color.RenderColor;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.text.FontInfo;
|
||||
import dev.loki.lovisual.render.engine.text.Fonts;
|
||||
import dev.loki.lovisual.render.engine.text.TextRenderer;
|
||||
import dev.loki.lovisual.util.media.MediaSessionService;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
@ModuleInfo(
|
||||
id = "kineticlyrics",
|
||||
displayName = "KineticLyrics",
|
||||
aliases = {"lyrics"},
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Рендерит текст текущей песни в 3D-пространстве вокруг игрока"
|
||||
)
|
||||
public final class KineticLyrics extends Module {
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
private final EnumValue<AnimationMode> animation =
|
||||
enumMode("animation", AnimationMode.LYRIC_FLOW, AnimationMode.values());
|
||||
private final EnumValue<ColorMode> colorMode =
|
||||
enumMode("color_mode", ColorMode.THEME, ColorMode.values());
|
||||
private final RGBAColorValue staticColor =
|
||||
visibleWhen(color("static_color", "#FFFFFFFF"), () -> colorMode.get() == ColorMode.STATIC);
|
||||
private final EnumValue<FontMode> fontMode =
|
||||
enumMode("font", FontMode.ONEST_BOLD, FontMode.values());
|
||||
private final EnumValue<SplitMode> splitMode =
|
||||
enumMode("split_mode", SplitMode.SMART_SPLIT, SplitMode.values());
|
||||
private final NumberValue<Integer> maxLines =
|
||||
num("max_lines", 3, 1, 6);
|
||||
private final NumberValue<Float> minDistance =
|
||||
num("min_distance", 2.0f, 1.2f, 4.0f);
|
||||
private final NumberValue<Float> maxDistance =
|
||||
num("max_distance", 4.5f, 2.0f, 7.0f);
|
||||
private final NumberValue<Float> arcSpread =
|
||||
num("arc_spread", 70.0f, 20.0f, 85.0f);
|
||||
private final NumberValue<Float> floatHeight =
|
||||
num("float_height", 0.5f, 0.1f, 2.0f);
|
||||
private final NumberValue<Integer> timeOffsetMs =
|
||||
num("time_offset_ms", 0, -5000, 5000);
|
||||
private final BooleanValue debugMode =
|
||||
bool("debug", false);
|
||||
|
||||
private final List<LyricLine> rawLyrics = new ArrayList<>();
|
||||
private final List<LyricLine> lyricsQueue = new ArrayList<>();
|
||||
private final List<LyricParticle3D> activeParticles = new CopyOnWriteArrayList<>();
|
||||
private int currentLyricIndex = 0;
|
||||
|
||||
private String lastTrackKey = "";
|
||||
private String currentTrackTitle = "";
|
||||
private String currentTrackArtist = "";
|
||||
private long lastAudioTimeMs = -1L;
|
||||
private boolean isPlaying = false;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
resetPlaybackState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
resetPlaybackState();
|
||||
}
|
||||
|
||||
private void resetPlaybackState() {
|
||||
activeParticles.clear();
|
||||
rawLyrics.clear();
|
||||
lyricsQueue.clear();
|
||||
lastTrackKey = "";
|
||||
currentTrackTitle = "";
|
||||
currentTrackArtist = "";
|
||||
currentLyricIndex = 0;
|
||||
lastAudioTimeMs = -1L;
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
if (!isEnabled() || mc.player == null) return;
|
||||
syncPlaybackTime();
|
||||
}
|
||||
|
||||
private void syncPlaybackTime() {
|
||||
MediaSessionService.Snapshot snapshot = MediaSessionService.get().snapshot();
|
||||
if (!snapshot.hasSession() || snapshot.title().isBlank()) {
|
||||
if (isPlaying) {
|
||||
isPlaying = false;
|
||||
activeParticles.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String title = snapshot.title();
|
||||
String artist = snapshot.artist();
|
||||
String key = (title + " - " + artist).toLowerCase();
|
||||
|
||||
if (!key.equalsIgnoreCase(lastTrackKey)) {
|
||||
loadTrack(title, artist, key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaying && !lyricsQueue.isEmpty()) {
|
||||
isPlaying = true;
|
||||
}
|
||||
if (lyricsQueue.isEmpty()) return;
|
||||
|
||||
long audioTimeMs = Math.max(0L, snapshot.predictedPositionSeconds() * 1000L + timeOffsetMs.get());
|
||||
|
||||
if (lastAudioTimeMs >= 0 && Math.abs(audioTimeMs - lastAudioTimeMs) > 1500L) {
|
||||
resyncQueue(audioTimeMs);
|
||||
}
|
||||
lastAudioTimeMs = audioTimeMs;
|
||||
|
||||
advanceQueue(audioTimeMs);
|
||||
}
|
||||
|
||||
private void loadTrack(String title, String artist, String key) {
|
||||
this.currentTrackTitle = title;
|
||||
this.currentTrackArtist = artist;
|
||||
this.lastTrackKey = key;
|
||||
|
||||
activeParticles.clear();
|
||||
rawLyrics.clear();
|
||||
lyricsQueue.clear();
|
||||
currentLyricIndex = 0;
|
||||
lastAudioTimeMs = -1L;
|
||||
isPlaying = false;
|
||||
|
||||
LrcLibClient.fetchLyricsAsync(title, artist, debugMode.get()).thenAccept(lines -> {
|
||||
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
|
||||
|
||||
activeParticles.clear();
|
||||
rawLyrics.clear();
|
||||
lyricsQueue.clear();
|
||||
rawLyrics.addAll(lines);
|
||||
rebuildLyricsQueue();
|
||||
currentLyricIndex = 0;
|
||||
isPlaying = !lyricsQueue.isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
private void rebuildLyricsQueue() {
|
||||
lyricsQueue.clear();
|
||||
if (splitMode.get() == SplitMode.SMART_SPLIT) {
|
||||
lyricsQueue.addAll(LyricLineSplitter.splitLongLines(rawLyrics));
|
||||
} else {
|
||||
lyricsQueue.addAll(rawLyrics);
|
||||
}
|
||||
}
|
||||
|
||||
private void resyncQueue(long audioTimeMs) {
|
||||
activeParticles.clear();
|
||||
currentLyricIndex = 0;
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
for (int i = 0; i < lyricsQueue.size(); i++) {
|
||||
LyricLine line = lyricsQueue.get(i);
|
||||
long nextStartMs = (i + 1 < lyricsQueue.size()) ? lyricsQueue.get(i + 1).timestampMs() : line.timestampMs() + 3500L;
|
||||
long durationMs = Math.max(1200L, Math.min(5000L, nextStartMs - line.timestampMs()));
|
||||
|
||||
if (audioTimeMs >= line.timestampMs()) {
|
||||
long timeSinceLine = audioTimeMs - line.timestampMs();
|
||||
if (timeSinceLine < durationMs) {
|
||||
spawnLyricParticle(line.text(), now - timeSinceLine, durationMs);
|
||||
}
|
||||
currentLyricIndex = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void advanceQueue(long audioTimeMs) {
|
||||
if (!isPlaying || lyricsQueue.isEmpty() || mc.player == null) return;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
while (currentLyricIndex < lyricsQueue.size()) {
|
||||
LyricLine line = lyricsQueue.get(currentLyricIndex);
|
||||
long nextStartMs = (currentLyricIndex + 1 < lyricsQueue.size()) ? lyricsQueue.get(currentLyricIndex + 1).timestampMs() : line.timestampMs() + 3500L;
|
||||
long durationMs = Math.max(1400L, Math.min(5500L, nextStartMs - line.timestampMs()));
|
||||
|
||||
if (audioTimeMs >= line.timestampMs()) {
|
||||
long timeSinceLine = audioTimeMs - line.timestampMs();
|
||||
if (timeSinceLine < durationMs) {
|
||||
spawnLyricParticle(line.text(), now - timeSinceLine, durationMs);
|
||||
}
|
||||
currentLyricIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
activeParticles.removeIf(particle -> particle.isDead(now));
|
||||
}
|
||||
|
||||
private Vec3 findNonOverlappingSpawnPos(Vec3 headPos, float cameraYaw) {
|
||||
double spread = arcSpread.get();
|
||||
double minD = minDistance.get();
|
||||
double maxD = Math.max(minD + 0.5, maxDistance.get());
|
||||
|
||||
Vec3 bestPos = null;
|
||||
double maxMinDistance = -1.0;
|
||||
|
||||
for (int attempt = 0; attempt < 16; attempt++) {
|
||||
double randomAngleOffset = ThreadLocalRandom.current().nextDouble(-spread, spread);
|
||||
double targetYawDeg = cameraYaw + randomAngleOffset;
|
||||
double distance = ThreadLocalRandom.current().nextDouble(minD, maxD);
|
||||
double yOffset = ThreadLocalRandom.current().nextDouble(-0.25, 0.55);
|
||||
|
||||
double yawRad = Math.toRadians(targetYawDeg);
|
||||
double dirX = -Math.sin(yawRad);
|
||||
double dirZ = Math.cos(yawRad);
|
||||
|
||||
Vec3 candidate = new Vec3(
|
||||
headPos.x + (dirX * distance),
|
||||
headPos.y + yOffset,
|
||||
headPos.z + (dirZ * distance)
|
||||
);
|
||||
|
||||
if (activeParticles.isEmpty()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
double minDistanceToOthers = Double.MAX_VALUE;
|
||||
for (LyricParticle3D active : activeParticles) {
|
||||
double d = candidate.distanceTo(active.getBasePosition());
|
||||
if (d < minDistanceToOthers) {
|
||||
minDistanceToOthers = d;
|
||||
}
|
||||
}
|
||||
|
||||
if (minDistanceToOthers >= 1.35) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (minDistanceToOthers > maxMinDistance) {
|
||||
maxMinDistance = minDistanceToOthers;
|
||||
bestPos = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestPos;
|
||||
}
|
||||
|
||||
private void spawnLyricParticle(String text, long spawnTimeMs, long durationMs) {
|
||||
if (mc.player == null || text == null || text.isBlank()) return;
|
||||
if (mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null) return;
|
||||
|
||||
Camera camera = mc.gameRenderer.mainCamera();
|
||||
Vec3 headPos = mc.player.getEyePosition();
|
||||
float cameraYaw = camera.yRot();
|
||||
|
||||
int maxAllowed = maxLines.get();
|
||||
while (activeParticles.size() >= maxAllowed) {
|
||||
activeParticles.remove(0);
|
||||
}
|
||||
|
||||
Vec3 spawnPos = findNonOverlappingSpawnPos(headPos, cameraYaw);
|
||||
if (spawnPos == null) return;
|
||||
|
||||
activeParticles.add(new LyricParticle3D(text, spawnPos, spawnTimeMs, durationMs));
|
||||
}
|
||||
|
||||
private TextRenderer getSelectedFont() {
|
||||
return Fonts.renderer(fontMode.get().family, FontInfo.Type.Regular, TextRenderer.get());
|
||||
}
|
||||
|
||||
private int getActiveColorRgb() {
|
||||
return switch (colorMode.get()) {
|
||||
case THEME -> Theme.theme().accent();
|
||||
case THEME_GRADIENT -> {
|
||||
float ratio = (float) (Math.sin(System.currentTimeMillis() / 450.0) * 0.5 + 0.5);
|
||||
int c1 = Theme.theme().accent();
|
||||
int c2 = Theme.theme().accentSoft();
|
||||
yield mixArgb(c1, c2, ratio);
|
||||
}
|
||||
case WHITE -> 0xFFFFFF;
|
||||
case STATIC -> staticColor.getArgb();
|
||||
};
|
||||
}
|
||||
|
||||
private static int mixArgb(int from, int to, float t) {
|
||||
t = Math.max(0.0f, Math.min(1.0f, t));
|
||||
int r = mixChannel((from >>> 16) & 0xFF, (to >>> 16) & 0xFF, t);
|
||||
int g = mixChannel((from >>> 8) & 0xFF, (to >>> 8) & 0xFF, t);
|
||||
int b = mixChannel(from & 0xFF, to & 0xFF, t);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
private static int mixChannel(int from, int to, float t) {
|
||||
return from + Math.round((to - from) * t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.AFTER_POST_PROCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (!isEnabled() || mc.level == null || mc.player == null) return;
|
||||
if (activeParticles.isEmpty()) return;
|
||||
|
||||
TextRenderer font = getSelectedFont();
|
||||
String animMode = animation.get().name();
|
||||
int colorRgb = getActiveColorRgb();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
for (LyricParticle3D particle : activeParticles) {
|
||||
particle.render(renderer, font, colorRgb, animMode, now);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderHudEngineForeground(Renderer2D renderer, TextRenderer textRenderer, GuiGraphicsExtractor ctx, float tickDelta) {
|
||||
if (!isEnabled() || !debugMode.get() || mc.player == null) return;
|
||||
|
||||
TextRenderer tr = Fonts.renderer("OnestMedium", FontInfo.Type.Regular, textRenderer);
|
||||
String trackLine = "Track: " + (currentTrackTitle.isEmpty() ? "None" : currentTrackTitle + " - " + currentTrackArtist);
|
||||
long displayTimeMs = Math.max(0L, lastAudioTimeMs);
|
||||
String timerLine = String.format("Audio time: %02d:%02d.%03d (%d ms)",
|
||||
displayTimeMs / 60_000,
|
||||
(displayTimeMs % 60_000) / 1000,
|
||||
displayTimeMs % 1000,
|
||||
displayTimeMs
|
||||
);
|
||||
String particlesLine = "Active particles: " + activeParticles.size();
|
||||
String queueLine = "Queue: " + currentLyricIndex + " / " + lyricsQueue.size() + " lines";
|
||||
|
||||
tr.begin(0.85);
|
||||
tr.render(trackLine, 10.0, 70.0, new RenderColor(0xFFDCDCDC), false);
|
||||
tr.render(timerLine, 10.0, 82.0, new RenderColor(0xFFFFFFFF), false);
|
||||
tr.render(particlesLine, 10.0, 94.0, new RenderColor(0xFF78FF78), false);
|
||||
tr.render(queueLine, 10.0, 106.0, new RenderColor(0xFFB4B4FF), false);
|
||||
tr.end();
|
||||
}
|
||||
|
||||
public enum AnimationMode implements EnumValue.IdProvider {
|
||||
LYRIC_FLOW("lyric_flow"),
|
||||
TYPEWRITER("typewriter"),
|
||||
POP_SCALE("pop_scale"),
|
||||
KINETIC_SLIDE("kinetic_slide"),
|
||||
FADE("fade");
|
||||
|
||||
private final String id;
|
||||
|
||||
AnimationMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ColorMode implements EnumValue.IdProvider {
|
||||
THEME("theme"),
|
||||
THEME_GRADIENT("theme_gradient"),
|
||||
WHITE("white"),
|
||||
STATIC("static");
|
||||
|
||||
private final String id;
|
||||
|
||||
ColorMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public enum FontMode implements EnumValue.IdProvider {
|
||||
ONEST_BOLD("onest_bold", "OnestBold"),
|
||||
ONEST_MEDIUM("onest_medium", "OnestMedium"),
|
||||
ONEST("onest", "Onest"),
|
||||
INTER_MEDIUM("inter_medium", "InterMedium");
|
||||
|
||||
private final String id;
|
||||
private final String family;
|
||||
|
||||
FontMode(String id, String family) {
|
||||
this.id = id;
|
||||
this.family = family;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public enum SplitMode implements EnumValue.IdProvider {
|
||||
SMART_SPLIT("smart_split"),
|
||||
FULL_LINE("full_line");
|
||||
|
||||
private final String id;
|
||||
|
||||
SplitMode(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Fetches synced lyrics from lrclib.net (no API key required).
|
||||
*/
|
||||
public final class LrcLibClient {
|
||||
private static final String API_GET = "https://lrclib.net/api/get";
|
||||
private static final String API_SEARCH = "https://lrclib.net/api/search";
|
||||
|
||||
private static final String USER_AGENT = "LoVisual/1.0";
|
||||
|
||||
private static final Map<String, List<LyricLine>> LYRICS_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(12))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private LrcLibClient() {
|
||||
}
|
||||
|
||||
public static CompletableFuture<List<LyricLine>> fetchLyricsAsync(String trackName, String artistName, boolean debugMode) {
|
||||
if (trackName == null || trackName.isBlank()) {
|
||||
return CompletableFuture.completedFuture(Collections.emptyList());
|
||||
}
|
||||
|
||||
String cacheKey = (trackName.trim() + " - " + (artistName != null ? artistName.trim() : "")).toLowerCase();
|
||||
List<LyricLine> cached = LYRICS_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] Cache hit: %s (%d lines)", cacheKey, cached.size());
|
||||
}
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
List<LyricLine> lines = tryFetchGet(trackName, artistName, debugMode);
|
||||
if (!lines.isEmpty()) {
|
||||
LYRICS_CACHE.put(cacheKey, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines = tryFetchSearch(trackName, artistName, debugMode);
|
||||
if (!lines.isEmpty()) {
|
||||
LYRICS_CACHE.put(cacheKey, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
return Collections.<LyricLine>emptyList();
|
||||
});
|
||||
}
|
||||
|
||||
private static List<LyricLine> tryFetchGet(String trackName, String artistName, boolean debugMode) {
|
||||
String cleanTrack = cleanTrackName(trackName);
|
||||
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
|
||||
|
||||
String query = String.format("?track_name=%s&artist_name=%s",
|
||||
URLEncoder.encode(cleanTrack, StandardCharsets.UTF_8),
|
||||
URLEncoder.encode(cleanArtist, StandardCharsets.UTF_8));
|
||||
|
||||
return executeRequest(API_GET + query, debugMode);
|
||||
}
|
||||
|
||||
private static List<LyricLine> tryFetchSearch(String trackName, String artistName, boolean debugMode) {
|
||||
String cleanTrack = cleanTrackName(trackName);
|
||||
String cleanArtist = artistName != null ? cleanTrackName(artistName) : "";
|
||||
String searchQuery = (cleanTrack + " " + cleanArtist).trim();
|
||||
|
||||
String query = "?q=" + URLEncoder.encode(searchQuery, StandardCharsets.UTF_8);
|
||||
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(API_SEARCH + query))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(Duration.ofSeconds(14))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
JsonArray array = JsonParser.parseString(response.body()).getAsJsonArray();
|
||||
for (JsonElement elem : array) {
|
||||
if (elem.isJsonObject()) {
|
||||
JsonObject obj = elem.getAsJsonObject();
|
||||
if (obj.has("syncedLyrics") && !obj.get("syncedLyrics").isJsonNull()) {
|
||||
String syncedLrc = obj.get("syncedLyrics").getAsString();
|
||||
List<LyricLine> parsed = LrcParser.parse(syncedLrc);
|
||||
if (!parsed.isEmpty()) {
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] Found lyrics via search fallback");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (response.statusCode() == 404) {
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] Search: track not found (404)");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (debugMode) {
|
||||
DebugLog.error("[KineticLyrics] Search fallback failed", e);
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private static List<LyricLine> executeRequest(String url, boolean debugMode) {
|
||||
for (int attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.timeout(Duration.ofSeconds(14))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
if (json.has("syncedLyrics") && !json.get("syncedLyrics").isJsonNull()) {
|
||||
String syncedLrc = json.get("syncedLyrics").getAsString();
|
||||
return LrcParser.parse(syncedLrc);
|
||||
}
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] No syncedLyrics in response");
|
||||
}
|
||||
return Collections.emptyList();
|
||||
} else if (response.statusCode() == 404) {
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] Track not found (404)");
|
||||
}
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
if (debugMode) {
|
||||
DebugLog.info("[KineticLyrics] Attempt %d returned %d", attempt, response.statusCode());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (debugMode) {
|
||||
DebugLog.error("[KineticLyrics] Attempt " + attempt + " failed", e);
|
||||
}
|
||||
if (attempt < 2) {
|
||||
try {
|
||||
Thread.sleep(400);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
static String cleanTrackName(String trackName) {
|
||||
if (trackName == null) return "";
|
||||
return trackName
|
||||
.replaceAll("\\(.*?\\)", "")
|
||||
.replaceAll("\\[.*?\\]", "")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class LrcParser {
|
||||
private static final Pattern LRC_PATTERN = Pattern.compile("\\[(\\d+):(\\d{2})(?:\\.(\\d{1,3}))?\\](.*)");
|
||||
|
||||
private LrcParser() {
|
||||
}
|
||||
|
||||
public static List<LyricLine> parse(String lrcContent) {
|
||||
if (lrcContent == null || lrcContent.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<LyricLine> lines = new ArrayList<>();
|
||||
String[] rawLines = lrcContent.split("\\r?\\n");
|
||||
|
||||
for (String line : rawLines) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty()) continue;
|
||||
|
||||
Matcher matcher = LRC_PATTERN.matcher(trimmed);
|
||||
if (matcher.matches()) {
|
||||
try {
|
||||
long minutes = Long.parseLong(matcher.group(1));
|
||||
long seconds = Long.parseLong(matcher.group(2));
|
||||
String fractionStr = matcher.group(3);
|
||||
|
||||
long millis = 0;
|
||||
if (fractionStr != null && !fractionStr.isEmpty()) {
|
||||
millis = Long.parseLong(fractionStr);
|
||||
if (fractionStr.length() == 1) {
|
||||
millis *= 100;
|
||||
} else if (fractionStr.length() == 2) {
|
||||
millis *= 10;
|
||||
}
|
||||
}
|
||||
|
||||
long totalTimeMs = (minutes * 60_000L) + (seconds * 1_000L) + millis;
|
||||
String text = matcher.group(4).trim();
|
||||
|
||||
if (!text.isEmpty()) {
|
||||
lines.add(new LyricLine(totalTimeMs, text));
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Collections.sort(lines);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
public record LyricLine(long timestampMs, String text) implements Comparable<LyricLine> {
|
||||
@Override
|
||||
public int compareTo(LyricLine o) {
|
||||
return Long.compare(this.timestampMs, o.timestampMs);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class LyricLineSplitter {
|
||||
private LyricLineSplitter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits long lyric lines (more than 4 words) into shorter rhythmic chunks with micro-timings.
|
||||
*/
|
||||
public static List<LyricLine> splitLongLines(List<LyricLine> originalLines) {
|
||||
if (originalLines == null || originalLines.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<LyricLine> result = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < originalLines.size(); i++) {
|
||||
LyricLine current = originalLines.get(i);
|
||||
long nextTimeMs = (i + 1 < originalLines.size()) ? originalLines.get(i + 1).timestampMs() : current.timestampMs() + 4000L;
|
||||
long lineDuration = Math.max(1500L, nextTimeMs - current.timestampMs());
|
||||
|
||||
String text = current.text().trim();
|
||||
String[] words = text.split("\\s+");
|
||||
|
||||
if (words.length <= 4) {
|
||||
result.add(current);
|
||||
} else {
|
||||
int wordsPerChunk = words.length > 7 ? 3 : 2;
|
||||
List<String> chunks = new ArrayList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int count = 0;
|
||||
|
||||
for (String word : words) {
|
||||
if (!sb.isEmpty()) sb.append(" ");
|
||||
sb.append(word);
|
||||
count++;
|
||||
if (count >= wordsPerChunk) {
|
||||
chunks.add(sb.toString());
|
||||
sb.setLength(0);
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
if (!sb.isEmpty()) {
|
||||
chunks.add(sb.toString());
|
||||
}
|
||||
|
||||
int numChunks = chunks.size();
|
||||
if (numChunks == 0) {
|
||||
result.add(current);
|
||||
continue;
|
||||
}
|
||||
long chunkDuration = lineDuration / numChunks;
|
||||
|
||||
for (int c = 0; c < numChunks; c++) {
|
||||
long chunkStartMs = current.timestampMs() + (c * chunkDuration);
|
||||
result.add(new LyricLine(chunkStartMs, chunks.get(c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Collections.sort(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.lyrics;
|
||||
|
||||
import dev.loki.lovisual.render.engine.color.RenderColor;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.text.TextRenderer;
|
||||
import dev.loki.lovisual.render.engine.text.WorldTextRenderer;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
public class LyricParticle3D {
|
||||
private static final double BASE_WORLD_SCALE = 0.0016;
|
||||
|
||||
private final String text;
|
||||
private final Vec3 basePosition;
|
||||
private final long spawnTimeMs;
|
||||
private final long durationMs;
|
||||
private boolean forceExpire = false;
|
||||
|
||||
public LyricParticle3D(String text, Vec3 basePosition, long spawnTimeMs, long durationMs) {
|
||||
this.text = text;
|
||||
this.basePosition = basePosition;
|
||||
this.spawnTimeMs = spawnTimeMs;
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
|
||||
public Vec3 getBasePosition() {
|
||||
return basePosition;
|
||||
}
|
||||
|
||||
public void dismiss() {
|
||||
this.forceExpire = true;
|
||||
}
|
||||
|
||||
public boolean isDead(long nowMs) {
|
||||
return forceExpire || (nowMs - spawnTimeMs >= durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders this lyric line as a camera-facing 3D billboard with a selectable entrance/exit animation.
|
||||
*/
|
||||
public void render(Renderer3D renderer, TextRenderer textRenderer, int colorRgb, String animMode, long nowMs) {
|
||||
long elapsed = nowMs - spawnTimeMs;
|
||||
if (elapsed < 0 || elapsed > durationMs || forceExpire) return;
|
||||
|
||||
float progress = Mth.clamp((float) elapsed / (float) durationMs, 0.0f, 1.0f);
|
||||
|
||||
float alpha = 1.0f;
|
||||
double offsetY = 0.0;
|
||||
float animScale = 1.0f;
|
||||
String displayText = this.text;
|
||||
|
||||
switch (animMode) {
|
||||
case "TYPEWRITER" -> {
|
||||
if (progress < 0.30f) {
|
||||
float typeProgress = progress / 0.30f;
|
||||
int visibleChars = Math.min(this.text.length(), (int) Math.ceil(this.text.length() * typeProgress));
|
||||
displayText = this.text.substring(0, Math.max(1, visibleChars));
|
||||
alpha = Math.min(1.0f, progress / 0.08f);
|
||||
offsetY = progress * 0.1;
|
||||
} else if (progress < 0.75f) {
|
||||
offsetY = progress * 0.1;
|
||||
} else {
|
||||
float eraseProgress = (progress - 0.75f) / 0.25f;
|
||||
int remainingChars = Math.max(0, this.text.length() - (int) Math.floor(this.text.length() * eraseProgress));
|
||||
displayText = this.text.substring(0, remainingChars);
|
||||
alpha = 1.0f - (eraseProgress * 0.7f);
|
||||
offsetY = progress * 0.1;
|
||||
}
|
||||
}
|
||||
case "POP_SCALE" -> {
|
||||
if (progress < 0.18f) {
|
||||
float t = progress / 0.18f;
|
||||
alpha = easeOutCubic(t);
|
||||
animScale = 0.35f + 0.65f * easeOutBack(t);
|
||||
} else if (progress >= 0.80f) {
|
||||
float t = (progress - 0.80f) / 0.20f;
|
||||
alpha = 1.0f - easeInCubic(t);
|
||||
animScale = 1.0f - 0.05f * t;
|
||||
}
|
||||
}
|
||||
case "KINETIC_SLIDE" -> {
|
||||
if (progress < 0.20f) {
|
||||
float t = progress / 0.20f;
|
||||
alpha = easeOutCubic(t);
|
||||
offsetY = -0.15 * (1.0f - easeOutCubic(t));
|
||||
animScale = 0.92f + 0.08f * t;
|
||||
} else if (progress >= 0.80f) {
|
||||
float t = (progress - 0.80f) / 0.20f;
|
||||
alpha = 1.0f - easeInCubic(t);
|
||||
offsetY = 0.05 * easeInCubic(t);
|
||||
animScale = 1.0f - 0.04f * t;
|
||||
}
|
||||
}
|
||||
case "FADE" -> {
|
||||
if (progress < 0.15f) {
|
||||
alpha = progress / 0.15f;
|
||||
} else if (progress >= 0.80f) {
|
||||
alpha = 1.0f - ((progress - 0.80f) / 0.20f);
|
||||
}
|
||||
}
|
||||
default -> { // LYRIC_FLOW
|
||||
if (progress < 0.15f) {
|
||||
float t = progress / 0.15f;
|
||||
alpha = easeOutCubic(t);
|
||||
offsetY = -0.06 * (1.0f - easeOutCubic(t));
|
||||
animScale = 0.93f + 0.07f * easeOutBack(t);
|
||||
} else if (progress >= 0.80f) {
|
||||
float t = (progress - 0.80f) / 0.20f;
|
||||
alpha = 1.0f - easeInCubic(t);
|
||||
offsetY = 0.04 * easeInCubic(t);
|
||||
animScale = 1.0f - 0.03f * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alpha = Mth.clamp(alpha, 0.0f, 1.0f);
|
||||
if (alpha <= 0.001f || displayText == null || displayText.isBlank()) return;
|
||||
|
||||
int alphaInt = (int) (alpha * 255.0f);
|
||||
RenderColor color = new RenderColor((alphaInt << 24) | (colorRgb & 0x00FFFFFF));
|
||||
|
||||
Vec3 anchor = basePosition.add(0.0, offsetY, 0.0);
|
||||
|
||||
WorldTextRenderer.Options options = WorldTextRenderer.Options.defaults()
|
||||
.withColor(color)
|
||||
.withCentered(true)
|
||||
.withShadow(true)
|
||||
.withWorldScale(BASE_WORLD_SCALE * animScale)
|
||||
.withDepthMode(Renderer3D.DepthMode.MAIN);
|
||||
|
||||
WorldTextRenderer.drawBillboard(renderer, textRenderer, displayText, anchor, options);
|
||||
}
|
||||
|
||||
private static float easeOutCubic(float t) {
|
||||
return 1.0f - (float) Math.pow(1.0f - t, 3);
|
||||
}
|
||||
|
||||
private static float easeInCubic(float t) {
|
||||
return (float) Math.pow(t, 3);
|
||||
}
|
||||
|
||||
private static float easeOutBack(float t) {
|
||||
float c1 = 1.70158f;
|
||||
float c3 = c1 + 1.0f;
|
||||
return 1.0f + c3 * (float) Math.pow(t - 1.0f, 3) + c1 * (float) Math.pow(t - 1.0f, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.features.module.modules.visuals.trollface;
|
||||
|
||||
import dev.loki.lovisual.config.values.ModeValue;
|
||||
import dev.loki.lovisual.config.values.NumberValue;
|
||||
import dev.loki.lovisual.features.module.Module;
|
||||
import dev.loki.lovisual.features.module.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.ModuleInfo;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.TextureStorage;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import dev.loki.lovisual.util.target.TargetManager;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
@ModuleInfo(
|
||||
id = "trollface_mask",
|
||||
displayName = "Trollface Mask",
|
||||
category = ModuleCategory.VISUALS
|
||||
)
|
||||
public class TrollfaceMask extends Module {
|
||||
|
||||
private static TrollfaceMask instance;
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
public final ModeValue target = mode(
|
||||
"trollfaceTarget", "Себя",
|
||||
"Себя", "Друзей", "Всех"
|
||||
);
|
||||
|
||||
public final ModeValue maskType = mode(
|
||||
"trollfaceMaskType", "Style 1",
|
||||
"Style 1", "Style 2", "Style 3",
|
||||
"Style 4", "Style 5", "Style 6"
|
||||
);
|
||||
|
||||
public final NumberValue<Float> size = num(
|
||||
"trollfaceSize", "size", 1.0f, 0.3f, 3.0f
|
||||
);
|
||||
|
||||
public final NumberValue<Float> alpha = num(
|
||||
"trollfaceAlpha", "alpha", 1.0f, 0.1f, 1.0f
|
||||
);
|
||||
|
||||
public TrollfaceMask() {
|
||||
instance = this;
|
||||
}
|
||||
|
||||
public static TrollfaceMask getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
if (mc.player == null || mc.level == null) return;
|
||||
|
||||
String targetMode = target.get();
|
||||
boolean selfMode = "Себя".equals(targetMode);
|
||||
|
||||
LivingEntity currentTarget = TargetManager.getTarget();
|
||||
boolean hasCrosshairTarget = currentTarget != null && currentTarget.isAlive();
|
||||
|
||||
if (!selfMode && !hasCrosshairTarget) return;
|
||||
|
||||
LivingEntity renderTarget;
|
||||
boolean shouldRender;
|
||||
|
||||
switch (targetMode) {
|
||||
case "Себя" -> {
|
||||
renderTarget = mc.player;
|
||||
shouldRender = true;
|
||||
}
|
||||
case "Друзей" -> {
|
||||
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
|
||||
renderTarget = currentTarget;
|
||||
shouldRender = true;
|
||||
}
|
||||
case "Всех" -> {
|
||||
if (!hasCrosshairTarget) { shouldRender = false; renderTarget = null; break; }
|
||||
renderTarget = currentTarget;
|
||||
shouldRender = true;
|
||||
}
|
||||
default -> {
|
||||
renderTarget = mc.player;
|
||||
shouldRender = true;
|
||||
}
|
||||
}
|
||||
if (!shouldRender || renderTarget == null) return;
|
||||
|
||||
Vec3 camPos = mc.gameRenderer.mainCamera().position();
|
||||
double headX = renderTarget.getX();
|
||||
double headY = renderTarget.getY() + renderTarget.getBbHeight() * 0.72;
|
||||
double headZ = renderTarget.getZ();
|
||||
|
||||
Vec3 headPos = new Vec3(headX, headY, headZ);
|
||||
Vec3 viewDir = camPos.subtract(headPos).normalize();
|
||||
|
||||
if (viewDir.lengthSqr() < 1.0E-8) return;
|
||||
|
||||
Vec3 upVec = new Vec3(0, 1, 0);
|
||||
Vec3 right = viewDir.cross(upVec).normalize();
|
||||
if (right.lengthSqr() < 1.0E-8) {
|
||||
right = new Vec3(1, 0, 0);
|
||||
}
|
||||
Vec3 actualUp = right.cross(viewDir).normalize();
|
||||
|
||||
float s = size.get();
|
||||
float alpha = this.alpha.get();
|
||||
int color = 0xFFFFFFFF;
|
||||
int r = (color >> 16) & 0xFF;
|
||||
int g = (color >> 8) & 0xFF;
|
||||
int b = color & 0xFF;
|
||||
int a = (int) (alpha * 255);
|
||||
|
||||
Vec3 p1 = headPos.add(right.scale(-s)).add(actualUp.scale(-s * 0.75));
|
||||
Vec3 p2 = headPos.add(right.scale(s)).add(actualUp.scale(-s * 0.75));
|
||||
Vec3 p3 = headPos.add(right.scale(s)).add(actualUp.scale(s * 0.75));
|
||||
Vec3 p4 = headPos.add(right.scale(-s)).add(actualUp.scale(s * 0.75));
|
||||
|
||||
int textureIndex = maskTypeIndex(maskType.get());
|
||||
Identifier texId = switch (textureIndex) {
|
||||
case 0 -> TextureStorage.TROLLFACE_1;
|
||||
case 1 -> TextureStorage.TROLLFACE_2;
|
||||
case 2 -> TextureStorage.TROLLFACE_3;
|
||||
case 3 -> TextureStorage.TROLLFACE_4;
|
||||
case 4 -> TextureStorage.TROLLFACE_5;
|
||||
case 5 -> TextureStorage.TROLLFACE_6;
|
||||
default -> TextureStorage.TROLLFACE_1;
|
||||
};
|
||||
|
||||
MeshBuilder mesh = renderer.batchTextured(LoVisualRenderPipelines.WORLD_TEXTURED, texId, Renderer3D.DepthMode.NONE);
|
||||
if (mesh == null) return;
|
||||
|
||||
mesh.ensureQuadCapacity();
|
||||
int i1 = mesh.vec3(p1.x, p1.y, p1.z).vec2(0, 1).color(r, g, b, a).next();
|
||||
int i2 = mesh.vec3(p2.x, p2.y, p2.z).vec2(1, 1).color(r, g, b, a).next();
|
||||
int i3 = mesh.vec3(p3.x, p3.y, p3.z).vec2(1, 0).color(r, g, b, a).next();
|
||||
int i4 = mesh.vec3(p4.x, p4.y, p4.z).vec2(0, 0).color(r, g, b, a).next();
|
||||
mesh.quad(i1, i2, i3, i4);
|
||||
}
|
||||
|
||||
private static int maskTypeIndex(String maskType) {
|
||||
if (maskType == null) return 0;
|
||||
return switch (maskType) {
|
||||
case "Style 1" -> 0;
|
||||
case "Style 2" -> 1;
|
||||
case "Style 3" -> 2;
|
||||
case "Style 4" -> 3;
|
||||
case "Style 5" -> 4;
|
||||
case "Style 6" -> 5;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import org.spongepowered.asm.mixin.injection.Inject;
|
|||
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
import dev.loki.lovisual.features.module.Modules;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.HoldMyItems;
|
||||
import dev.loki.lovisual.features.module.modules.visuals.ViewModel;
|
||||
|
||||
@Environment(EnvType.CLIENT)
|
||||
|
|
@ -83,6 +84,20 @@ public abstract class ItemInHandRendererMixin {
|
|||
return player != null && (player.isInWater() || player.isInLava());
|
||||
}
|
||||
|
||||
@ModifyVariable(
|
||||
method = "submitArmWithItem",
|
||||
at = @At("HEAD"),
|
||||
argsOnly = true,
|
||||
index = 7)
|
||||
private float lovisual$holdMyItemsEquipCurve(float equipProgress, AbstractClientPlayer player, float tickDelta,
|
||||
float pitch, InteractionHand hand) {
|
||||
HoldMyItems hmi = Modules.get(HoldMyItems.class);
|
||||
if (hmi == null || !hmi.isEnabled()) return equipProgress;
|
||||
if (hand == InteractionHand.OFF_HAND && !hmi.offhandToo.get()) return equipProgress;
|
||||
|
||||
return hmi.shapeEquipProgress(equipProgress);
|
||||
}
|
||||
|
||||
@Inject(
|
||||
method = "renderItem(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/item/ItemDisplayContext;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V",
|
||||
at = @At(
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ public final class LoVisualMixinPlugin implements IMixinConfigPlugin {
|
|||
"dev.loki.lovisual.mixins.xaero.minimap.AbstractWaypointRenderProviderMixin"
|
||||
);
|
||||
private static final Set<String> OPTIONAL_XAERO_WORLDMAP_MIXINS = Set.of(
|
||||
"dev.loki.lovisual.mixins.xaero.worldmap.WorldMapWaypointAccessor",
|
||||
"dev.loki.lovisual.mixins.xaero.worldmap.WaypointRenderProviderMixin"
|
||||
);
|
||||
private static final Set<String> OPTIONAL_MORECULLING_MIXINS = Set.of(
|
||||
|
|
|
|||
|
|
@ -189,21 +189,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
|
|||
// FREECAM + INTERACT
|
||||
// ===============================
|
||||
|
||||
// Reach (FREECAM)
|
||||
Reach reachModule = Modules.get(Reach.class);
|
||||
if (reachModule != null && reachModule.isEnabled()) {
|
||||
EntityHitResult hit = reachModule.raycastEntities(
|
||||
mc.player, start, dir
|
||||
);
|
||||
|
||||
if (hit != null && hit.getEntity() instanceof LivingEntity target && target.isAlive()) {
|
||||
mc.hitResult = hit;
|
||||
mc.crosshairPickEntity = target;
|
||||
ci.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks (FREECAM)
|
||||
double reach = mc.player.blockInteractionRange();
|
||||
Vec3 end = start.add(dir.scale(reach));
|
||||
|
|
@ -220,16 +205,6 @@ public class MinecraftMixin implements MinecraftGameConfigHolder {
|
|||
ci.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
Reach reachModule = Modules.get(Reach.class);
|
||||
if (reachModule != null && reachModule.isEnabled()) {
|
||||
EntityHitResult entityHit = reachModule.raycastEntities(mc.player);
|
||||
if (entityHit != null && entityHit.getEntity() instanceof LivingEntity target && target.isAlive()) {
|
||||
mc.hitResult = entityHit;
|
||||
mc.crosshairPickEntity = target;
|
||||
ci.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "pick", at = @At("RETURN"))
|
||||
|
|
|
|||
|
|
@ -163,9 +163,5 @@ public class MultiPlayerGameModeMixin {
|
|||
TargetManager.onAttack(target);
|
||||
}
|
||||
|
||||
@Inject(method = "attack(Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/entity/Entity;)V",
|
||||
at = @At("RETURN"))
|
||||
private void lovisual$attributeSwapAfterClientAttack(Player player, Entity target, CallbackInfo ci) {
|
||||
// AttributeSwap module removed; no post-attack shield break remains.
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.mixins.xaero.worldmap;
|
||||
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
import xaero.map.mods.gui.Waypoint;
|
||||
|
||||
@Pseudo
|
||||
@Mixin(Waypoint.class)
|
||||
public interface WorldMapWaypointAccessor {
|
||||
|
||||
@Accessor
|
||||
void setX(int x);
|
||||
|
||||
@Accessor
|
||||
void setY(int y);
|
||||
|
||||
@Accessor
|
||||
void setZ(int z);
|
||||
|
||||
@Accessor
|
||||
void setColor(int color);
|
||||
}
|
||||
|
|
@ -42,6 +42,20 @@ public enum TextureStorage {
|
|||
Identifier.fromNamespaceAndPath("lovisual", "textures/particles/p11.png"),
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/particles/p12.png")
|
||||
};
|
||||
public static final Identifier TROLLFACE_1 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_1.png");
|
||||
public static final Identifier TROLLFACE_2 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_2.png");
|
||||
public static final Identifier TROLLFACE_3 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_3.png");
|
||||
public static final Identifier TROLLFACE_4 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_4.png");
|
||||
public static final Identifier TROLLFACE_5 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_5.png");
|
||||
public static final Identifier TROLLFACE_6 =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_6.png");
|
||||
public static final Identifier TROLLFACE_CLASSIC =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/trollface/trollface_classic.png");
|
||||
public static final Identifier CAPTURE =
|
||||
Identifier.fromNamespaceAndPath("lovisual", "textures/hud/elements/capture.png");
|
||||
|
||||
|
|
@ -58,6 +72,13 @@ public enum TextureStorage {
|
|||
tm.getTexture(FUNNEL_DISTORTION);
|
||||
tm.getTexture(PARTICLE_STARS);
|
||||
tm.getTexture(CAPTURE);
|
||||
tm.getTexture(TROLLFACE_1);
|
||||
tm.getTexture(TROLLFACE_2);
|
||||
tm.getTexture(TROLLFACE_3);
|
||||
tm.getTexture(TROLLFACE_4);
|
||||
tm.getTexture(TROLLFACE_5);
|
||||
tm.getTexture(TROLLFACE_6);
|
||||
tm.getTexture(TROLLFACE_CLASSIC);
|
||||
for (Identifier id : RANDOM_PARTICLES) {
|
||||
tm.getTexture(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ public enum LoVisualRenderPipelines {
|
|||
public static final Identifier SHADER_POST_FX_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/post_fx.frag");
|
||||
public static final Identifier SHADER_MOTION_BLUR_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/motion_blur.frag");
|
||||
public static final Identifier SHADER_DEPTH_OF_FIELD_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/depth_of_field.frag");
|
||||
public static final Identifier SHADER_WET_SURFACE_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/wet_surface.frag");
|
||||
public static final Identifier SHADER_HEAT_FX_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/heat_fx.frag");
|
||||
public static final Identifier SHADER_ESP_GRADIENT_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/shader_esp_gradient.frag");
|
||||
public static final Identifier SHADER_ESP_SHADOW_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/shader_esp_shadow.frag");
|
||||
|
|
@ -121,6 +122,9 @@ public enum LoVisualRenderPipelines {
|
|||
public static final Identifier SHADER_SKY_SUN_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_sun.frag");
|
||||
public static final Identifier SHADER_MENU_BACKGROUND_WAVES_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/menu_background_waves.frag");
|
||||
public static final Identifier SHADER_MENU_BACKGROUND_AURORA_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/menu_background_aurora.frag");
|
||||
public static final Identifier SHADER_SKY_SHADER_SKY_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_sky.frag");
|
||||
public static final Identifier SHADER_SKY_SHADER_WATER_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_water.frag");
|
||||
public static final Identifier SHADER_SKY_SHADER_CAUSTIC_FRAG = Identifier.fromNamespaceAndPath("lovisual", "shaders/sky_shader_caustic.frag");
|
||||
private static final List<RenderPipeline> PIPELINES = new ArrayList<>();
|
||||
public static final RenderPipeline GUI_TEXTURE_LOOKUP = add(new ExtendedRenderPipelineBuilder(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED_SNIPPET)
|
||||
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/gui_texture_lookup"))
|
||||
|
|
@ -735,6 +739,45 @@ public enum LoVisualRenderPipelines {
|
|||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
/**
|
||||
* Fullscreen SkyShader passes (pos2). Sky/water/caustic share the same uniform layout.
|
||||
*/
|
||||
public static final RenderPipeline SKY_SHADER_SKY = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
|
||||
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_sky"))
|
||||
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
|
||||
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
|
||||
.withFragmentShader(SHADER_SKY_SHADER_SKY_FRAG)
|
||||
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
|
||||
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
|
||||
.withDepthWrite(false)
|
||||
.withBlend(BlendFunction.TRANSLUCENT)
|
||||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
public static final RenderPipeline SKY_SHADER_WATER = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
|
||||
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_water"))
|
||||
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
|
||||
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
|
||||
.withFragmentShader(SHADER_SKY_SHADER_WATER_FRAG)
|
||||
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
|
||||
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
|
||||
.withDepthWrite(false)
|
||||
.withBlend(BlendFunction.TRANSLUCENT)
|
||||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
public static final RenderPipeline SKY_SHADER_CAUSTIC = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
|
||||
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/sky_shader_caustic"))
|
||||
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
|
||||
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
|
||||
.withFragmentShader(SHADER_SKY_SHADER_CAUSTIC_FRAG)
|
||||
.withUniform("SkyShader", UniformType.UNIFORM_BUFFER)
|
||||
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
|
||||
.withDepthWrite(false)
|
||||
.withBlend(BlendFunction.TRANSLUCENT)
|
||||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
/**
|
||||
* Fullscreen main menu background (pos2).
|
||||
*/
|
||||
|
|
@ -822,6 +865,23 @@ public enum LoVisualRenderPipelines {
|
|||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
/**
|
||||
* Fullscreen wet-ground screen-space reflection pass (pos2).
|
||||
*/
|
||||
public static final RenderPipeline WET_SURFACE = add(new ExtendedRenderPipelineBuilder(MESH_UNIFORMS)
|
||||
.withLocation(Identifier.fromNamespaceAndPath("lovisual", "pipeline/wet_surface"))
|
||||
.withVertexFormat(LoVisualVertexFormats.POS2, com.mojang.blaze3d.PrimitiveTopology.TRIANGLES)
|
||||
.withVertexShader(SHADER_DAMAGE_TINT_VERT)
|
||||
.withFragmentShader(SHADER_WET_SURFACE_FRAG)
|
||||
.withSampler("u_Texture")
|
||||
.withSampler("u_MainDepth")
|
||||
.withUniform("WetSurface", UniformType.UNIFORM_BUFFER)
|
||||
.withDepthTestFunction(DepthTestFunction.NO_DEPTH_TEST)
|
||||
.withDepthWrite(false)
|
||||
.withBlend(BlendFunction.TRANSLUCENT)
|
||||
.withCull(false)
|
||||
.build()
|
||||
);
|
||||
/**
|
||||
* Fullscreen heat distortion + vignette (pos2).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.render.engine.postprocess;
|
||||
|
||||
import com.mojang.blaze3d.ProjectionType;
|
||||
import com.mojang.blaze3d.buffers.GpuBufferSlice;
|
||||
import com.mojang.blaze3d.pipeline.RenderPipeline;
|
||||
import com.mojang.blaze3d.pipeline.RenderTarget;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import dev.loki.lovisual.render.engine.RenderState;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||
import dev.loki.lovisual.render.engine.renderer.MeshRenderer;
|
||||
import dev.loki.lovisual.render.engine.uniform.impl.SkyShaderUniforms;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
|
||||
import net.minecraft.util.Util;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Fullscreen SkyShader pass: sky / water / caustic procedural GLSL effect drawn behind the world,
|
||||
* with a camera-direction-driven ray so it reads correctly regardless of look direction.
|
||||
*/
|
||||
public enum SkyShaderRenderer {
|
||||
;
|
||||
|
||||
public enum Mode {
|
||||
SKY, WATER, CAUSTIC
|
||||
}
|
||||
|
||||
private static final Matrix4f IDENTITY = new Matrix4f();
|
||||
private static ProjectionMatrixBuffer projection;
|
||||
private static long startMillis = -1L;
|
||||
|
||||
public static void reset() {
|
||||
startMillis = -1L;
|
||||
}
|
||||
|
||||
public static void render(Minecraft mc, Mode mode, float speed, float scale, float intensity,
|
||||
float alpha, int colorRgb) {
|
||||
if (mc == null || mc.level == null || mc.player == null) return;
|
||||
|
||||
RenderTarget fb = mc.gameRenderer.mainRenderTarget();
|
||||
if (fb == null) return;
|
||||
|
||||
int w = mc.getWindow().getWidth();
|
||||
int h = mc.getWindow().getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
FullScreenRenderer.ensureInit();
|
||||
|
||||
if (startMillis < 0) startMillis = Util.getMillis();
|
||||
float time = (Util.getMillis() - startMillis) / 1000.0f;
|
||||
|
||||
float cr = ((colorRgb >>> 16) & 0xFF) / 255f;
|
||||
float cg = ((colorRgb >>> 8) & 0xFF) / 255f;
|
||||
float cb = (colorRgb & 0xFF) / 255f;
|
||||
|
||||
Camera cam = mc.gameRenderer.mainCamera();
|
||||
float yawRad = (float) Math.toRadians(-cam.yRot());
|
||||
float pitchRad = (float) Math.toRadians(cam.xRot());
|
||||
float fov = (float) mc.options.fov().get().intValue();
|
||||
|
||||
SkyShaderUniforms.update(time, speed, scale, intensity, w, h, alpha, fov, cr, cg, cb, yawRad, pitchRad);
|
||||
|
||||
var mv = RenderSystem.getModelViewStack();
|
||||
boolean pushedModelView = false;
|
||||
|
||||
GpuBufferSlice prevProj = RenderSystem.getProjectionMatrixBuffer();
|
||||
ProjectionType prevProjType = RenderSystem.getProjectionType();
|
||||
Matrix4f prevMeshProjection = MeshRenderer.projection();
|
||||
boolean prevRendering3D = RenderState.rendering3D;
|
||||
|
||||
try {
|
||||
mv.pushMatrix();
|
||||
pushedModelView = true;
|
||||
mv.identity();
|
||||
|
||||
if (projection == null) {
|
||||
projection = new ProjectionMatrixBuffer("lovisual-sky-shader-projection");
|
||||
}
|
||||
Matrix4f proj = IDENTITY.identity();
|
||||
RenderSystem.setProjectionMatrix(projection.getBuffer(proj), ProjectionType.PERSPECTIVE);
|
||||
MeshRenderer.setProjection(proj);
|
||||
RenderState.rendering3D = false;
|
||||
|
||||
RenderPipeline pipeline = switch (mode) {
|
||||
case WATER -> LoVisualRenderPipelines.SKY_SHADER_WATER;
|
||||
case CAUSTIC -> LoVisualRenderPipelines.SKY_SHADER_CAUSTIC;
|
||||
default -> LoVisualRenderPipelines.SKY_SHADER_SKY;
|
||||
};
|
||||
|
||||
FullScreenRenderer.begin("LoVisual Fullscreen Pass")
|
||||
.attachment(fb)
|
||||
.pipeline(pipeline)
|
||||
.uniform("SkyShader", SkyShaderUniforms.get())
|
||||
.end();
|
||||
} finally {
|
||||
if (pushedModelView) {
|
||||
mv.popMatrix();
|
||||
}
|
||||
MeshRenderer.setProjection(prevMeshProjection);
|
||||
RenderState.rendering3D = prevRendering3D;
|
||||
if (prevProj != null && prevProjType != null) {
|
||||
RenderSystem.setProjectionMatrix(prevProj, prevProjType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.render.engine.uniform.impl;
|
||||
|
||||
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
|
||||
|
||||
import com.mojang.blaze3d.buffers.GpuBufferSlice;
|
||||
import com.mojang.blaze3d.buffers.Std140Builder;
|
||||
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
|
||||
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
|
||||
|
||||
public enum SkyShaderUniforms {
|
||||
;
|
||||
public static final int SIZE = new Std140SizeCalculator()
|
||||
.putVec4()
|
||||
.putVec4()
|
||||
.putVec4()
|
||||
.putVec4()
|
||||
.get();
|
||||
|
||||
private static final Data DATA = new Data();
|
||||
private static final String UNIFORM_NAME = "LoVisual - SkyShader UBO";
|
||||
private static final int EXPECTED_WRITES_PER_FRAME = 4;
|
||||
|
||||
public static void update(float time, float speed, float scale, float intensity,
|
||||
float width, float height, float alpha, float fov,
|
||||
float colorR, float colorG, float colorB,
|
||||
float cameraDirX, float cameraDirY) {
|
||||
DATA.params0[0] = time;
|
||||
DATA.params0[1] = speed;
|
||||
DATA.params0[2] = scale;
|
||||
DATA.params0[3] = intensity;
|
||||
|
||||
DATA.params1[0] = width;
|
||||
DATA.params1[1] = height;
|
||||
DATA.params1[2] = alpha;
|
||||
DATA.params1[3] = fov;
|
||||
|
||||
DATA.color[0] = colorR;
|
||||
DATA.color[1] = colorG;
|
||||
DATA.color[2] = colorB;
|
||||
DATA.color[3] = 0.0f;
|
||||
|
||||
DATA.cameraDir[0] = cameraDirX;
|
||||
DATA.cameraDir[1] = cameraDirY;
|
||||
DATA.cameraDir[2] = 0.0f;
|
||||
DATA.cameraDir[3] = 0.0f;
|
||||
|
||||
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
|
||||
}
|
||||
|
||||
public static GpuBufferSlice get() {
|
||||
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
|
||||
}
|
||||
|
||||
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
|
||||
private final float[] params0 = new float[4];
|
||||
private final float[] params1 = new float[4];
|
||||
private final float[] color = new float[4];
|
||||
private final float[] cameraDir = new float[4];
|
||||
|
||||
@Override
|
||||
public void write(java.nio.ByteBuffer buffer) {
|
||||
Std140Builder.intoBuffer(buffer)
|
||||
.putFloat(params0[0]).putFloat(params0[1]).putFloat(params0[2]).putFloat(params0[3])
|
||||
.putFloat(params1[0]).putFloat(params1[1]).putFloat(params1[2]).putFloat(params1[3])
|
||||
.putFloat(color[0]).putFloat(color[1]).putFloat(color[2]).putFloat(color[3])
|
||||
.putFloat(cameraDir[0]).putFloat(cameraDir[1]).putFloat(cameraDir[2]).putFloat(cameraDir[3]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.render.engine.uniform.impl;
|
||||
|
||||
import com.mojang.blaze3d.buffers.GpuBufferSlice;
|
||||
import com.mojang.blaze3d.buffers.Std140Builder;
|
||||
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
|
||||
import org.joml.Matrix4f;
|
||||
import dev.loki.lovisual.render.engine.core.LoVisualRenderSystem;
|
||||
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
|
||||
|
||||
public enum WetSurfaceUniforms {
|
||||
;
|
||||
public static final int SIZE = new Std140SizeCalculator()
|
||||
.putMat4f()
|
||||
.putMat4f()
|
||||
.putFloat().putFloat().putFloat().putFloat()
|
||||
.putFloat().putFloat().putFloat().putFloat()
|
||||
.putFloat().putFloat().putFloat().putFloat()
|
||||
.putFloat().putFloat().putFloat().putFloat()
|
||||
.get();
|
||||
|
||||
private static final Data DATA = new Data();
|
||||
private static final String UNIFORM_NAME = "LoVisual - WetSurface UBO";
|
||||
private static final int EXPECTED_WRITES_PER_FRAME = 2;
|
||||
|
||||
public static void update(Matrix4f projection,
|
||||
Matrix4f invView,
|
||||
float camX, float camY, float camZ,
|
||||
float invW, float invH, float time, boolean mainDepthEnabled,
|
||||
float wetness, float puddleCoverage, float reflectionStrength, float rippleStrength,
|
||||
float maxDistance, int reflectionSteps, float rainAmount, boolean ripplesEnabled) {
|
||||
DATA.projection.set(projection);
|
||||
DATA.invView.set(invView);
|
||||
DATA.camX = camX;
|
||||
DATA.camY = camY;
|
||||
DATA.camZ = camZ;
|
||||
DATA.invW = invW;
|
||||
DATA.invH = invH;
|
||||
DATA.time = time;
|
||||
DATA.mainDepthEnabled = mainDepthEnabled ? 1.0f : 0.0f;
|
||||
DATA.wetness = wetness;
|
||||
DATA.puddleCoverage = puddleCoverage;
|
||||
DATA.reflectionStrength = reflectionStrength;
|
||||
DATA.rippleStrength = rippleStrength;
|
||||
DATA.maxDistance = maxDistance;
|
||||
DATA.reflectionSteps = reflectionSteps;
|
||||
DATA.rainAmount = rainAmount;
|
||||
DATA.ripplesEnabled = ripplesEnabled ? 1.0f : 0.0f;
|
||||
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
|
||||
}
|
||||
|
||||
public static GpuBufferSlice get() {
|
||||
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
|
||||
}
|
||||
|
||||
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
|
||||
private final Matrix4f projection = new Matrix4f();
|
||||
private final Matrix4f invView = new Matrix4f();
|
||||
private float camX, camY, camZ;
|
||||
private float invW, invH, time, mainDepthEnabled;
|
||||
private float wetness, puddleCoverage, reflectionStrength, rippleStrength;
|
||||
private float maxDistance, reflectionSteps, rainAmount, ripplesEnabled;
|
||||
|
||||
@Override
|
||||
public void write(java.nio.ByteBuffer buffer) {
|
||||
Std140Builder b = Std140Builder.intoBuffer(buffer);
|
||||
b.putMat4f(projection);
|
||||
b.putMat4f(invView);
|
||||
b.putFloat(camX).putFloat(camY).putFloat(camZ).putFloat(0.0f);
|
||||
b.putFloat(invW).putFloat(invH).putFloat(time).putFloat(mainDepthEnabled);
|
||||
b.putFloat(wetness).putFloat(puddleCoverage).putFloat(reflectionStrength).putFloat(rippleStrength);
|
||||
b.putFloat(maxDistance).putFloat(reflectionSteps).putFloat(rainAmount).putFloat(ripplesEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ package dev.loki.lovisual.util.combat;
|
|||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.network.protocol.game.ServerboundInteractPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundAttackPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
|
|
@ -155,7 +155,9 @@ public enum AttackUtil {
|
|||
}
|
||||
|
||||
notifyLocalAttack(target);
|
||||
mc.getConnection().send(new ServerboundInteractPacket(target.getId(), null, null, player.isShiftKeyDown()));
|
||||
mc.getConnection().send(new ServerboundAttackPacket(target.getId()));
|
||||
player.attack(target);
|
||||
player.resetAttackStrengthTicker();
|
||||
ResetAttackCooldown.resetAttackCooldown(player);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,721 +0,0 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.util.combat;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.enchantment.Enchantment;
|
||||
import net.minecraft.world.item.enchantment.EnchantmentHelper;
|
||||
import net.minecraft.world.item.enchantment.Enchantments;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import dev.loki.lovisual.events.EventHandler;
|
||||
import dev.loki.lovisual.events.impl.GameTickEvent;
|
||||
import dev.loki.lovisual.events.impl.PacketEvent;
|
||||
import dev.loki.lovisual.util.network.BacktrackController;
|
||||
import dev.loki.lovisual.util.player.NetworkStatsUtil;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Local model for Vulcan 2.9.7 Reach A/B.
|
||||
* This does not change combat by itself; Reach/Hitbox should query planReach()
|
||||
* and actual sent attacks are observed through the same attack -> movement split.
|
||||
*/
|
||||
public final class VulcanReachController {
|
||||
|
||||
public static final VulcanReachController INSTANCE = new VulcanReachController();
|
||||
|
||||
private static final double REACH_A_CONFIG_MAX = 3.03;
|
||||
private static final double REACH_A_BODY_SUBTRACT = 0.52;
|
||||
private static final double REACH_A_BUFFER_MAX = 3.0;
|
||||
private static final double REACH_A_LATCH_RELEASE = REACH_A_BUFFER_MAX - 1.0;
|
||||
private static final double REACH_A_DECAY = 0.035;
|
||||
private static final double REACH_A_NO_HISTORY_DECAY = 0.025;
|
||||
private static final double REACH_A_RECENT_SERVER_ALLOWANCE = 1.15;
|
||||
private static final double REACH_A_LIQUID_ALLOWANCE = 0.1;
|
||||
private static final double REACH_A_SANITY_CAP = 8.0;
|
||||
|
||||
private static final double REACH_B_CONFIG_MAX = 3.15;
|
||||
private static final double REACH_B_BODY_SUBTRACT = 0.565;
|
||||
private static final double REACH_B_BUFFER_MAX = 6.0;
|
||||
private static final double REACH_B_LATCH_RELEASE = REACH_B_BUFFER_MAX - 1.0;
|
||||
private static final double REACH_B_DECAY = 0.225;
|
||||
private static final double REACH_B_BASE_EXTRA = 0.05;
|
||||
private static final double REACH_B_HORIZONTAL_ALLOWANCE = 0.9;
|
||||
private static final double REACH_B_VERTICAL_ALLOWANCE = 0.66;
|
||||
private static final double REACH_B_MOVING_STATE_ALLOWANCE = 0.32;
|
||||
private static final double REACH_B_FRESH_STATE_ALLOWANCE = 0.35;
|
||||
private static final double REACH_B_STATE_COUNTER_SCALE = 0.125;
|
||||
private static final double REACH_B_HIGH_LATENCY_ALLOWANCE = 1.25;
|
||||
private static final double REACH_B_SANITY_CAP = 6.0;
|
||||
|
||||
private static final long SERVER_POSITION_WINDOW_MS = 1500L;
|
||||
private static final long JOIN_WORLD_WINDOW_MS = 1500L;
|
||||
private static final long HISTORY_MAX_AGE_MS = 700L;
|
||||
private static final long HISTORY_STRICT_WINDOW_MS = 250L;
|
||||
private static final int HISTORY_MAX_SAMPLES = 20;
|
||||
private static final double BUFFER_EPSILON = 1.0E-6;
|
||||
private static final double FAST_POST_HIT_MOVE_THRESHOLD = 0.18;
|
||||
private static final double FAST_POST_HIT_APPROACH_ALLOWANCE = 0.45;
|
||||
|
||||
private final Minecraft mc = Minecraft.getInstance();
|
||||
private final Map<UUID, ArrayDeque<HistorySample>> targetHistory = new HashMap<>();
|
||||
|
||||
private double reachABuffer;
|
||||
private double reachBBuffer;
|
||||
private int pendingAttackEntityId = -1;
|
||||
private long pendingAttackMs;
|
||||
private Vec3 pendingAttackPosition;
|
||||
private long lastServerPositionMs = Long.MIN_VALUE;
|
||||
private long lastJoinOrWorldMs = Long.MIN_VALUE;
|
||||
private Vec3 lastSentPosition;
|
||||
private double lastSentHorizontalDelta;
|
||||
private double lastSentVerticalDelta;
|
||||
private int movementStateTicks;
|
||||
private boolean reachAClampLatched;
|
||||
private boolean reachBClampLatched;
|
||||
|
||||
private VulcanReachController() {
|
||||
}
|
||||
|
||||
public ReachPlan planReach(Entity target, double requestedReach) {
|
||||
LocalPlayer player = mc.player;
|
||||
if (player == null || target == null) {
|
||||
return ReachPlan.empty(requestedReach);
|
||||
}
|
||||
|
||||
CheckState state = buildState(player, target, false);
|
||||
updateClampLatches();
|
||||
|
||||
boolean forceReachAClamp = reachAClampLatched && !state.reachAExempt;
|
||||
boolean forceReachBClamp = reachBClampLatched && !state.reachBExempt;
|
||||
|
||||
double strictReachALimit = state.reachAExempt
|
||||
? requestedReach
|
||||
: reachAEdgeLimit(player, target, state.reachAAllowed);
|
||||
double reachALimit = state.reachAExempt || (state.noReachAHistory && !reachAClampLatched)
|
||||
? requestedReach
|
||||
: strictReachALimit;
|
||||
if (state.fastPostHitApproach) {
|
||||
reachALimit = Math.min(requestedReach, reachALimit + FAST_POST_HIT_APPROACH_ALLOWANCE);
|
||||
}
|
||||
double reachBLimit = state.reachBExempt
|
||||
? requestedReach
|
||||
: reachBEdgeLimit(player, target, state.reachBAllowed);
|
||||
|
||||
boolean overB = !state.reachBExempt
|
||||
&& requestedReach > reachBLimit;
|
||||
boolean spendsA = !state.reachAExempt
|
||||
&& !state.noReachAHistory
|
||||
&& requestedReach > strictReachALimit
|
||||
&& state.reachAMeasured < REACH_A_SANITY_CAP;
|
||||
boolean spendsB = overB && state.reachBMeasured < REACH_B_SANITY_CAP;
|
||||
|
||||
double clamped = requestedReach;
|
||||
if (forceReachAClamp) {
|
||||
clamped = Math.min(clamped, strictReachALimit);
|
||||
}
|
||||
if (forceReachBClamp) {
|
||||
clamped = Math.min(clamped, reachBLimit);
|
||||
}
|
||||
|
||||
return new ReachPlan(
|
||||
requestedReach,
|
||||
Math.max(0.0, clamped),
|
||||
Math.max(0.0, reachALimit),
|
||||
Math.max(0.0, reachBLimit),
|
||||
reachABuffer,
|
||||
reachBBuffer,
|
||||
state.reachAExempt,
|
||||
state.reachBExempt,
|
||||
state.noReachAHistory,
|
||||
spendsA,
|
||||
spendsB,
|
||||
state.reason
|
||||
);
|
||||
}
|
||||
|
||||
public double clampReach(Entity target, double requestedReach) {
|
||||
return planReach(target, requestedReach).allowedReach();
|
||||
}
|
||||
|
||||
public HudSnapshot hudSnapshot(Entity target, double requestedReach) {
|
||||
if (!(target instanceof Player)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ReachPlan plan = planReach(target, requestedReach);
|
||||
boolean dirty = reachABuffer > 1.0E-4
|
||||
|| reachBBuffer > 1.0E-4
|
||||
|| reachAClampLatched
|
||||
|| reachBClampLatched;
|
||||
|
||||
double displayReach = plan.allowedReach();
|
||||
boolean clamped = displayReach + BUFFER_EPSILON < requestedReach;
|
||||
|
||||
int rawRemaining = Math.min(
|
||||
longHitsRemaining(reachABuffer, REACH_A_BUFFER_MAX, reachAClampLatched),
|
||||
longHitsRemaining(reachBBuffer, REACH_B_BUFFER_MAX, reachBClampLatched)
|
||||
);
|
||||
int remaining = clamped ? 0 : Math.max(1, rawRemaining);
|
||||
|
||||
return new HudSnapshot(displayReach, Math.max(0, remaining), dirty, plan.reason());
|
||||
}
|
||||
|
||||
public HudSnapshot hudSnapshot(double requestedReach) {
|
||||
boolean dirty = reachABuffer > 1.0E-4
|
||||
|| reachBBuffer > 1.0E-4
|
||||
|| reachAClampLatched
|
||||
|| reachBClampLatched;
|
||||
|
||||
int rawRemaining = Math.min(
|
||||
longHitsRemaining(reachABuffer, REACH_A_BUFFER_MAX, reachAClampLatched),
|
||||
longHitsRemaining(reachBBuffer, REACH_B_BUFFER_MAX, reachBClampLatched)
|
||||
);
|
||||
int remaining = reachAClampLatched || reachBClampLatched ? 0 : Math.max(1, rawRemaining);
|
||||
|
||||
return new HudSnapshot(requestedReach, Math.max(0, remaining), dirty, "no_target");
|
||||
}
|
||||
|
||||
public double getReachABuffer() {
|
||||
return reachABuffer;
|
||||
}
|
||||
|
||||
public double getReachBBuffer() {
|
||||
return reachBBuffer;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
reachABuffer = 0.0;
|
||||
reachBBuffer = 0.0;
|
||||
pendingAttackEntityId = -1;
|
||||
pendingAttackMs = 0L;
|
||||
pendingAttackPosition = null;
|
||||
lastSentPosition = null;
|
||||
lastSentHorizontalDelta = 0.0;
|
||||
lastSentVerticalDelta = 0.0;
|
||||
movementStateTicks = 0;
|
||||
reachAClampLatched = false;
|
||||
reachBClampLatched = false;
|
||||
targetHistory.clear();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onGameTick(GameTickEvent event) {
|
||||
LocalPlayer player = mc.player;
|
||||
if (player == null || mc.level == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
for (Player other : mc.level.players()) {
|
||||
if (other == player || !other.isAlive()) {
|
||||
continue;
|
||||
}
|
||||
addHistorySample(other, now);
|
||||
}
|
||||
pruneHistory(now);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketSendPost(PacketEvent.SendPost event) {
|
||||
if (event.getPacket() instanceof ServerboundInteractPacket packet) {
|
||||
onAttackPacket(packet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getPacket() instanceof ServerboundMovePlayerPacket packet) {
|
||||
updateSentMovement(packet);
|
||||
evaluatePendingAttack();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
private void onPacketReceive(PacketEvent.Receive event) {
|
||||
if (event.getPacket() instanceof ClientboundPlayerPositionPacket) {
|
||||
lastServerPositionMs = System.currentTimeMillis();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getPacket() instanceof ClientboundLoginPacket) {
|
||||
clear();
|
||||
lastJoinOrWorldMs = System.currentTimeMillis();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.getPacket() instanceof ClientboundRespawnPacket) {
|
||||
pendingAttackEntityId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void onAttackPacket(ServerboundInteractPacket packet) {
|
||||
if (!isAttack(packet) || mc.level == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int entityId = packet.entityId();
|
||||
Entity target = mc.level.getEntity(entityId);
|
||||
if (!(target instanceof Player)) {
|
||||
pendingAttackEntityId = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAttackEntityId = entityId;
|
||||
pendingAttackMs = System.currentTimeMillis();
|
||||
pendingAttackPosition = mc.player != null ? mc.player.position() : null;
|
||||
}
|
||||
|
||||
private void evaluatePendingAttack() {
|
||||
if (pendingAttackEntityId < 0 || mc.level == null || mc.player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entity target = mc.level.getEntity(pendingAttackEntityId);
|
||||
pendingAttackEntityId = -1;
|
||||
|
||||
if (!(target instanceof Player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
CheckState state = buildState(mc.player, target, true);
|
||||
applyReachA(state);
|
||||
applyReachB(state);
|
||||
pendingAttackPosition = null;
|
||||
}
|
||||
|
||||
private void applyReachA(CheckState state) {
|
||||
if (state.reachAExempt) {
|
||||
decayABuffer(REACH_A_DECAY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.noReachAHistory) {
|
||||
decayABuffer(REACH_A_NO_HISTORY_DECAY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.reachAMeasured > state.reachAAllowed && state.reachAMeasured < REACH_A_SANITY_CAP) {
|
||||
reachABuffer = increaseLocalBuffer(reachABuffer, REACH_A_BUFFER_MAX);
|
||||
} else {
|
||||
decayABuffer(REACH_A_DECAY);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyReachB(CheckState state) {
|
||||
if (state.reachBExempt) {
|
||||
decayBBuffer(REACH_B_DECAY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.reachBMeasured > state.reachBAllowed && state.reachBMeasured < REACH_B_SANITY_CAP) {
|
||||
reachBBuffer = increaseLocalBuffer(reachBBuffer, REACH_B_BUFFER_MAX);
|
||||
} else {
|
||||
decayBBuffer(REACH_B_DECAY);
|
||||
}
|
||||
}
|
||||
|
||||
private CheckState buildState(LocalPlayer player, Entity target, boolean evaluatingPendingAttack) {
|
||||
if (!(target instanceof Player targetPlayer)) {
|
||||
return CheckState.exempt("non_player");
|
||||
}
|
||||
|
||||
boolean attackerVehicle = player.isPassenger();
|
||||
boolean targetVehicle = targetPlayer.isPassenger();
|
||||
boolean attackerBoat = attackerVehicle && isBoatLike(player.getVehicle());
|
||||
boolean targetBoat = targetVehicle && isBoatLike(targetPlayer.getVehicle());
|
||||
boolean creative = player.isCreative();
|
||||
boolean liquid = player.isInWater() || player.isUnderWater() || player.isInLava();
|
||||
boolean recentServer = isRecent(lastServerPositionMs, SERVER_POSITION_WINDOW_MS);
|
||||
boolean recentJoinWorld = isRecent(lastJoinOrWorldMs, JOIN_WORLD_WINDOW_MS);
|
||||
boolean knockback = hasKnockback(player.getMainHandItem());
|
||||
boolean fastPostHitApproach = evaluatingPendingAttack && isFastPostHitApproach(player, targetPlayer);
|
||||
|
||||
boolean reachAExempt = creative || attackerVehicle || attackerBoat || targetVehicle || targetBoat || knockback;
|
||||
boolean reachBExempt = creative || attackerVehicle || attackerBoat || targetVehicle || targetBoat || recentServer || recentJoinWorld;
|
||||
|
||||
double baseReach = getInteractionRange(player);
|
||||
double reachAAllowed = baseReach + (REACH_A_CONFIG_MAX - 3.0);
|
||||
if (recentServer) {
|
||||
reachAAllowed += REACH_A_RECENT_SERVER_ALLOWANCE;
|
||||
}
|
||||
if (liquid) {
|
||||
reachAAllowed += REACH_A_LIQUID_ALLOWANCE;
|
||||
}
|
||||
|
||||
double reachBAllowed = baseReach + (REACH_B_CONFIG_MAX - 3.0) + REACH_B_BASE_EXTRA;
|
||||
reachBAllowed += lastSentHorizontalDelta * REACH_B_HORIZONTAL_ALLOWANCE;
|
||||
reachBAllowed += lastSentVerticalDelta * REACH_B_VERTICAL_ALLOWANCE;
|
||||
if (lastSentHorizontalDelta > 1.0E-4 || lastSentVerticalDelta > 1.0E-4) {
|
||||
reachBAllowed += REACH_B_MOVING_STATE_ALLOWANCE;
|
||||
}
|
||||
if (movementStateTicks < 3) {
|
||||
reachBAllowed += REACH_B_FRESH_STATE_ALLOWANCE;
|
||||
}
|
||||
reachBAllowed += Math.min(4, movementStateTicks) * REACH_B_STATE_COUNTER_SCALE;
|
||||
if (NetworkStatsUtil.getPing(mc) > 100) {
|
||||
reachBAllowed += REACH_B_HIGH_LATENCY_ALLOWANCE;
|
||||
}
|
||||
|
||||
double reachAMeasured = reachAMeasureDistance(player, targetPlayer);
|
||||
double reachBMeasured = Math.max(0.0, player.position().distanceTo(targetPlayer.position()) - REACH_B_BODY_SUBTRACT);
|
||||
boolean noHistory = reachAMeasured < 0.0;
|
||||
|
||||
String reason = resolveReason(reachAExempt, reachBExempt, noHistory, recentServer, recentJoinWorld, liquid, knockback);
|
||||
return new CheckState(
|
||||
reachAExempt,
|
||||
reachBExempt,
|
||||
noHistory,
|
||||
reachAAllowed,
|
||||
reachBAllowed,
|
||||
reachAMeasured,
|
||||
reachBMeasured,
|
||||
fastPostHitApproach,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
private double reachAMeasureDistance(LocalPlayer player, Player target) {
|
||||
if (shouldUseBacktrackHistory(target)) {
|
||||
return reachAHistoryDistance(player, target);
|
||||
}
|
||||
|
||||
Vec3 playerPos = player.position();
|
||||
Vec3 targetPos = target.position();
|
||||
double dx = playerPos.x - targetPos.x;
|
||||
double dz = playerPos.z - targetPos.z;
|
||||
return Math.max(0.0, Math.sqrt(dx * dx + dz * dz) - REACH_A_BODY_SUBTRACT);
|
||||
}
|
||||
|
||||
private boolean shouldUseBacktrackHistory(Entity target) {
|
||||
BacktrackController backtrack = BacktrackController.INSTANCE;
|
||||
Entity backtrackTarget = backtrack.getTarget();
|
||||
return backtrack.isEnabled()
|
||||
&& backtrack.isLagging()
|
||||
&& backtrackTarget != null
|
||||
&& target != null
|
||||
&& backtrackTarget.getId() == target.getId();
|
||||
}
|
||||
|
||||
private double reachAHistoryDistance(LocalPlayer player, Player target) {
|
||||
ArrayDeque<HistorySample> samples = targetHistory.get(target.getUUID());
|
||||
if (samples == null || samples.isEmpty()) {
|
||||
return -1.0;
|
||||
}
|
||||
|
||||
long now = pendingAttackMs > 0L ? pendingAttackMs : System.currentTimeMillis();
|
||||
Vec3 playerPos = player.position();
|
||||
double best = Double.MAX_VALUE;
|
||||
|
||||
for (HistorySample sample : samples) {
|
||||
long age = Math.abs(now - sample.timeMs());
|
||||
if (age > HISTORY_STRICT_WINDOW_MS) {
|
||||
continue;
|
||||
}
|
||||
double dx = playerPos.x - sample.pos().x;
|
||||
double dz = playerPos.z - sample.pos().z;
|
||||
best = Math.min(best, Math.sqrt(dx * dx + dz * dz) - REACH_A_BODY_SUBTRACT);
|
||||
}
|
||||
|
||||
return best == Double.MAX_VALUE ? -1.0 : Math.max(0.0, best);
|
||||
}
|
||||
|
||||
private double reachAEdgeLimit(LocalPlayer player, Entity target, double allowed) {
|
||||
Vec3 from = player.getEyePosition(1.0F);
|
||||
AABB box = target.getBoundingBox();
|
||||
Vec3 center = box.getCenter();
|
||||
|
||||
double centerDistance = Math.sqrt(squaredHorizontalDistance(from.x, from.z, center.x, center.z));
|
||||
double edgeDistance = horizontalBoxDistance(box, from);
|
||||
double centerToEdgeReduction = Math.max(0.0, centerDistance - edgeDistance);
|
||||
|
||||
return Math.max(0.0, allowed + REACH_A_BODY_SUBTRACT - centerToEdgeReduction);
|
||||
}
|
||||
|
||||
private double reachBEdgeLimit(LocalPlayer player, Entity target, double allowed) {
|
||||
Vec3 from = player.getEyePosition(1.0F);
|
||||
AABB box = target.getBoundingBox();
|
||||
Vec3 center = box.getCenter();
|
||||
|
||||
double centerDistance = from.distanceTo(center);
|
||||
double edgeDistance = Math.sqrt(boxDistanceSq(box, from));
|
||||
double centerToEdgeReduction = Math.max(0.0, centerDistance - edgeDistance);
|
||||
|
||||
return Math.max(0.0, allowed + REACH_B_BODY_SUBTRACT - centerToEdgeReduction);
|
||||
}
|
||||
|
||||
private double horizontalBoxDistance(AABB box, Vec3 point) {
|
||||
double dx = 0.0;
|
||||
if (point.x < box.minX) dx = box.minX - point.x;
|
||||
else if (point.x > box.maxX) dx = point.x - box.maxX;
|
||||
|
||||
double dz = 0.0;
|
||||
if (point.z < box.minZ) dz = box.minZ - point.z;
|
||||
else if (point.z > box.maxZ) dz = point.z - box.maxZ;
|
||||
|
||||
return Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
private double boxDistanceSq(AABB box, Vec3 point) {
|
||||
double dx = 0.0;
|
||||
if (point.x < box.minX) dx = box.minX - point.x;
|
||||
else if (point.x > box.maxX) dx = point.x - box.maxX;
|
||||
|
||||
double dy = 0.0;
|
||||
if (point.y < box.minY) dy = box.minY - point.y;
|
||||
else if (point.y > box.maxY) dy = point.y - box.maxY;
|
||||
|
||||
double dz = 0.0;
|
||||
if (point.z < box.minZ) dz = box.minZ - point.z;
|
||||
else if (point.z > box.maxZ) dz = point.z - box.maxZ;
|
||||
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
private double squaredHorizontalDistance(double x1, double z1, double x2, double z2) {
|
||||
double dx = x1 - x2;
|
||||
double dz = z1 - z2;
|
||||
return dx * dx + dz * dz;
|
||||
}
|
||||
|
||||
private void updateSentMovement(ServerboundMovePlayerPacket packet) {
|
||||
LocalPlayer player = mc.player;
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Vec3 current = new Vec3(
|
||||
packet.getX(player.getX()),
|
||||
packet.getY(player.getY()),
|
||||
packet.getZ(player.getZ())
|
||||
);
|
||||
|
||||
if (lastSentPosition == null) {
|
||||
lastSentPosition = current;
|
||||
lastSentHorizontalDelta = 0.0;
|
||||
lastSentVerticalDelta = 0.0;
|
||||
movementStateTicks = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
double dx = current.x - lastSentPosition.x;
|
||||
double dy = current.y - lastSentPosition.y;
|
||||
double dz = current.z - lastSentPosition.z;
|
||||
lastSentHorizontalDelta = Math.sqrt(dx * dx + dz * dz);
|
||||
lastSentVerticalDelta = Math.abs(dy);
|
||||
lastSentPosition = current;
|
||||
|
||||
if (lastSentHorizontalDelta > 1.0E-4 || lastSentVerticalDelta > 1.0E-4) {
|
||||
movementStateTicks = Mth.clamp(movementStateTicks + 1, 0, 20);
|
||||
} else {
|
||||
movementStateTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFastPostHitApproach(LocalPlayer player, Player target) {
|
||||
if (pendingAttackPosition == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Vec3 current = player.position();
|
||||
double moved = Math.sqrt(squaredHorizontalDistance(
|
||||
pendingAttackPosition.x,
|
||||
pendingAttackPosition.z,
|
||||
current.x,
|
||||
current.z
|
||||
));
|
||||
if (moved < FAST_POST_HIT_MOVE_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Vec3 targetCenter = target.getBoundingBox().getCenter();
|
||||
double then = Math.sqrt(squaredHorizontalDistance(
|
||||
pendingAttackPosition.x,
|
||||
pendingAttackPosition.z,
|
||||
targetCenter.x,
|
||||
targetCenter.z
|
||||
));
|
||||
double now = Math.sqrt(squaredHorizontalDistance(
|
||||
current.x,
|
||||
current.z,
|
||||
targetCenter.x,
|
||||
targetCenter.z
|
||||
));
|
||||
|
||||
return now + 0.03 < then;
|
||||
}
|
||||
|
||||
private void addHistorySample(Player player, long now) {
|
||||
ArrayDeque<HistorySample> samples = targetHistory.computeIfAbsent(player.getUUID(), ignored -> new ArrayDeque<>());
|
||||
samples.addLast(new HistorySample(player.position(), now));
|
||||
while (samples.size() > HISTORY_MAX_SAMPLES) {
|
||||
samples.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private void pruneHistory(long now) {
|
||||
Iterator<Map.Entry<UUID, ArrayDeque<HistorySample>>> iterator = targetHistory.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
ArrayDeque<HistorySample> samples = iterator.next().getValue();
|
||||
while (!samples.isEmpty() && now - samples.peekFirst().timeMs() > HISTORY_MAX_AGE_MS) {
|
||||
samples.removeFirst();
|
||||
}
|
||||
if (samples.isEmpty()) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private double getInteractionRange(LocalPlayer player) {
|
||||
try {
|
||||
return player.getAttributeValue(Attributes.ENTITY_INTERACTION_RANGE);
|
||||
} catch (Throwable ignored) {
|
||||
return 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasKnockback(ItemStack stack) {
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Holder<Enchantment> entry = getEnchantmentEntry(Enchantments.KNOCKBACK);
|
||||
return entry != null && EnchantmentHelper.getItemEnchantmentLevel(entry, stack) > 0;
|
||||
}
|
||||
|
||||
private Holder<Enchantment> getEnchantmentEntry(ResourceKey<Enchantment> key) {
|
||||
if (mc.level == null) {
|
||||
return null;
|
||||
}
|
||||
Registry<Enchantment> registry = mc.level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT);
|
||||
Enchantment enchantment = registry.getValue(key);
|
||||
return enchantment != null ? registry.wrapAsHolder(enchantment) : null;
|
||||
}
|
||||
|
||||
private boolean isBoatLike(Entity entity) {
|
||||
return entity != null && entity.getType().toString().toLowerCase().contains("boat");
|
||||
}
|
||||
|
||||
private boolean isAttack(ServerboundInteractPacket packet) {
|
||||
return packet.hand() == null && packet.location() == null;
|
||||
}
|
||||
|
||||
private boolean isAtPreAlert(double buffer, double max) {
|
||||
return buffer + 1.0 > max + BUFFER_EPSILON;
|
||||
}
|
||||
|
||||
private void updateClampLatches() {
|
||||
if (isAtPreAlert(reachABuffer, REACH_A_BUFFER_MAX)) {
|
||||
reachAClampLatched = true;
|
||||
} else if (reachABuffer <= REACH_A_LATCH_RELEASE) {
|
||||
reachAClampLatched = false;
|
||||
}
|
||||
|
||||
if (isAtPreAlert(reachBBuffer, REACH_B_BUFFER_MAX)) {
|
||||
reachBClampLatched = true;
|
||||
} else if (reachBBuffer <= REACH_B_LATCH_RELEASE) {
|
||||
reachBClampLatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRecent(long timestamp, long windowMs) {
|
||||
return timestamp != Long.MIN_VALUE && System.currentTimeMillis() - timestamp <= windowMs;
|
||||
}
|
||||
|
||||
private void decayABuffer(double amount) {
|
||||
reachABuffer = Math.max(0.0, reachABuffer - amount);
|
||||
}
|
||||
|
||||
private void decayBBuffer(double amount) {
|
||||
reachBBuffer = Math.max(0.0, reachBBuffer - amount);
|
||||
}
|
||||
|
||||
private double increaseLocalBuffer(double buffer, double max) {
|
||||
return Math.min(max, buffer + 1.0);
|
||||
}
|
||||
|
||||
private int longHitsRemaining(double buffer, double max, boolean latched) {
|
||||
if (latched || isAtPreAlert(buffer, max)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, (int) Math.floor(max - buffer + BUFFER_EPSILON));
|
||||
}
|
||||
|
||||
private String resolveReason(boolean reachAExempt,
|
||||
boolean reachBExempt,
|
||||
boolean noHistory,
|
||||
boolean recentServer,
|
||||
boolean recentJoinWorld,
|
||||
boolean liquid,
|
||||
boolean knockback) {
|
||||
if (recentServer) return "server_position";
|
||||
if (recentJoinWorld) return "join_world";
|
||||
if (noHistory) return "no_history";
|
||||
if (liquid) return "liquid";
|
||||
if (knockback) return "knockback";
|
||||
if (reachAExempt || reachBExempt) return "exempt";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
public record ReachPlan(
|
||||
double requestedReach,
|
||||
double allowedReach,
|
||||
double reachALimit,
|
||||
double reachBLimit,
|
||||
double reachABuffer,
|
||||
double reachBBuffer,
|
||||
boolean reachAExempt,
|
||||
boolean reachBExempt,
|
||||
boolean noReachAHistory,
|
||||
boolean usesReachABuffer,
|
||||
boolean usesReachBBuffer,
|
||||
String reason
|
||||
) {
|
||||
private static ReachPlan empty(double requestedReach) {
|
||||
return new ReachPlan(requestedReach, requestedReach, requestedReach, requestedReach,
|
||||
0.0, 0.0, true, true, true, false, false, "empty");
|
||||
}
|
||||
}
|
||||
|
||||
public record HudSnapshot(
|
||||
double displayReach,
|
||||
int longHitsRemaining,
|
||||
boolean dirty,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
||||
private record HistorySample(Vec3 pos, long timeMs) {
|
||||
}
|
||||
|
||||
private record CheckState(
|
||||
boolean reachAExempt,
|
||||
boolean reachBExempt,
|
||||
boolean noReachAHistory,
|
||||
double reachAAllowed,
|
||||
double reachBAllowed,
|
||||
double reachAMeasured,
|
||||
double reachBMeasured,
|
||||
boolean fastPostHitApproach,
|
||||
String reason
|
||||
) {
|
||||
private static CheckState exempt(String reason) {
|
||||
return new CheckState(true, true, true, 0.0, 0.0, -1.0, -1.0, false, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -588,6 +588,9 @@
|
|||
"setting.targetesp.mode.option.ring": "Ring",
|
||||
"setting.targetesp.mode.option.capture_mark": "Capture mark",
|
||||
"setting.targetesp.mode.option.crystals": "Crystals",
|
||||
"setting.targetesp.mode.option.blackhole": "Blackhole",
|
||||
"setting.targetesp.mode.option.vortex": "Vortex",
|
||||
"setting.targetesp.vortex_speed": "Vortex speed",
|
||||
"setting.targetesp.color": "Color",
|
||||
"setting.targetesp.color_mode": "Color mode",
|
||||
"setting.targetesp.color_mode.option.Static": "Static",
|
||||
|
|
@ -2820,6 +2823,8 @@
|
|||
"setting.common.shulker_bg_radius": "Background radius",
|
||||
"setting.common.shulker_bg_softness": "Background softness",
|
||||
"setting.common.shulker_padding": "Padding",
|
||||
"setting.common.tooltip_font": "Tooltip font",
|
||||
"setting.common.tooltip_text_size": "Tooltip text size",
|
||||
"setting.common.shulker_preview": "Shulker Preview",
|
||||
"setting.common.shulker_preview_hold": "Preview hold",
|
||||
"setting.common.shulker_slot_bg_color": "Slot background color",
|
||||
|
|
|
|||
|
|
@ -523,6 +523,9 @@
|
|||
"setting.targetesp.mode.option.ring": "Кольцо",
|
||||
"setting.targetesp.mode.option.capture_mark": "Метка захвата",
|
||||
"setting.targetesp.mode.option.crystals": "Кристаллы",
|
||||
"setting.targetesp.mode.option.blackhole": "Чёрная дыра",
|
||||
"setting.targetesp.mode.option.vortex": "Вихрь",
|
||||
"setting.targetesp.vortex_speed": "Скорость вихря",
|
||||
"setting.targetesp.color": "Цвет",
|
||||
"setting.targetesp.color_mode": "Режим цвета",
|
||||
"setting.targetesp.color_mode.option.Static": "Статичный",
|
||||
|
|
@ -2619,6 +2622,8 @@
|
|||
"setting.common.shulker_bg_radius": "Радиус фона",
|
||||
"setting.common.shulker_bg_softness": "Мягкость фона",
|
||||
"setting.common.shulker_padding": "Отступ",
|
||||
"setting.common.tooltip_font": "Шрифт тултипа",
|
||||
"setting.common.tooltip_text_size": "Размер текста тултипа",
|
||||
"setting.common.shulker_preview": "Превью шалкера",
|
||||
"setting.common.shulker_preview_hold": "Удержание превью",
|
||||
"setting.common.shulker_slot_bg_color": "Цвет фона слота",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
#version 330 core
|
||||
|
||||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Portions of this file are based on, adapted from, or implemented
|
||||
* with reference to a "SkyShader" module distributed for another
|
||||
* Minecraft client. The original attribution was not preserved.
|
||||
* This implementation has since been substantially modified for LoVisual.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
layout (std140) uniform SkyShader {
|
||||
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
|
||||
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
|
||||
vec4 u_Color; // rgb = base color
|
||||
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
|
||||
};
|
||||
|
||||
in vec2 v_TexCoord;
|
||||
|
||||
#define MAX_ITER 4
|
||||
|
||||
mat3 rotX(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3(1.0, 0.0, 0.0,
|
||||
0.0, c, s,
|
||||
0.0, -s, c);
|
||||
}
|
||||
mat3 rotY(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3( c, 0.0, s,
|
||||
0.0, 1.0, 0.0,
|
||||
-s, 0.0, c);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = u_Params0.x;
|
||||
float speed = u_Params0.y;
|
||||
float scale = u_Params0.z;
|
||||
float intensity = u_Params0.w;
|
||||
|
||||
vec2 res = u_Params1.xy;
|
||||
float alpha = u_Params1.z;
|
||||
float fov = u_Params1.w;
|
||||
|
||||
vec3 baseColor = u_Color.rgb;
|
||||
|
||||
vec2 sp = v_TexCoord * 2.0 - 1.0;
|
||||
float aspect = res.x / max(res.y, 1.0);
|
||||
|
||||
float tanV = tan(radians(fov) * 0.5);
|
||||
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
|
||||
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
|
||||
|
||||
vec3 p = rayW * scale;
|
||||
vec3 i = p;
|
||||
float c = 1.0;
|
||||
float inten = intensity;
|
||||
|
||||
for (int n = 0; n < MAX_ITER; n++) {
|
||||
float t = time * speed * (1.0 - (3.0 / float(n + 1)));
|
||||
i = p + vec3(
|
||||
cos(t - i.x) + sin(t + i.y),
|
||||
sin(t - i.y) + cos(t + i.z),
|
||||
cos(t - i.z) + sin(t + i.x)
|
||||
);
|
||||
c += 1.0 / length(vec3(
|
||||
p.x / (sin(i.x + t) / inten),
|
||||
p.y / (cos(i.y + t) / inten),
|
||||
p.z / (sin(i.z + t) / inten)
|
||||
));
|
||||
}
|
||||
|
||||
c /= float(MAX_ITER);
|
||||
c = 1.5 - sqrt(c);
|
||||
float brightness = c * c * c * c;
|
||||
|
||||
// Sharper, more crystalline highlights than the original for a proper caustic look.
|
||||
float sharp = pow(brightness, 1.4);
|
||||
vec3 color = baseColor * sharp * 1.8 + baseColor * 0.15;
|
||||
|
||||
float vignette = smoothstep(1.4, 0.15, length(sp));
|
||||
color *= mix(0.7, 1.0, vignette);
|
||||
|
||||
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#version 330 core
|
||||
|
||||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Portions of this file are based on, adapted from, or implemented
|
||||
* with reference to a "SkyShader" module distributed for another
|
||||
* Minecraft client. The original attribution was not preserved.
|
||||
* This implementation has since been substantially modified for LoVisual.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
layout (std140) uniform SkyShader {
|
||||
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
|
||||
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
|
||||
vec4 u_Color; // rgb = base color
|
||||
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
|
||||
};
|
||||
|
||||
in vec2 v_TexCoord;
|
||||
|
||||
mat3 rotX(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3(1.0, 0.0, 0.0,
|
||||
0.0, c, s,
|
||||
0.0, -s, c);
|
||||
}
|
||||
mat3 rotY(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3( c, 0.0, s,
|
||||
0.0, 1.0, 0.0,
|
||||
-s, 0.0, c);
|
||||
}
|
||||
|
||||
float wave(vec3 dir, float phase, float scale, float speed, float time) {
|
||||
return sin(dir.x * scale + time * speed + phase)
|
||||
* sin(dir.z * scale * 0.7 - time * speed * 0.8 + phase * 1.7)
|
||||
* sin(dir.y * scale * 1.3 + time * speed * 0.5 + phase * 2.3);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = u_Params0.x;
|
||||
float speed = u_Params0.y;
|
||||
float scale = u_Params0.z;
|
||||
float intensity = u_Params0.w;
|
||||
|
||||
vec2 res = u_Params1.xy;
|
||||
float alpha = u_Params1.z;
|
||||
float fov = u_Params1.w;
|
||||
|
||||
vec3 baseColor = u_Color.rgb;
|
||||
|
||||
vec2 sp = v_TexCoord * 2.0 - 1.0;
|
||||
float aspect = res.x / max(res.y, 1.0);
|
||||
|
||||
float tanV = tan(radians(fov) * 0.5);
|
||||
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
|
||||
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
|
||||
|
||||
float h = clamp(rayW.y, -1.0, 1.0);
|
||||
|
||||
// Vertical atmosphere gradient with a warmer, higher-contrast horizon glow.
|
||||
vec3 zenith = baseColor * 0.12;
|
||||
vec3 horizon = baseColor * 0.95 + vec3(0.05, 0.03, 0.01);
|
||||
vec3 base = mix(horizon, zenith, pow(clamp(h, 0.0, 1.0), 0.55));
|
||||
base += baseColor * 0.4 * exp(-abs(h) * 4.5);
|
||||
|
||||
// Flowing aurora bands, seamless on the full sphere (world-space direction).
|
||||
float bands = 0.0;
|
||||
bands += wave(rayW, 0.0, scale, speed, time);
|
||||
bands += wave(rayW * 1.9 + 13.7, 2.1, scale, speed, time) * 0.55;
|
||||
bands += wave(rayW * 3.7 + 41.3, 4.7, scale, speed, time) * 0.3;
|
||||
bands *= intensity * 40.0;
|
||||
|
||||
// Fade aurora towards the horizon so it hugs the upper sky, softened edge.
|
||||
float auroraMask = smoothstep(-0.1, 0.5, h);
|
||||
vec3 aurora = baseColor * bands * auroraMask;
|
||||
|
||||
// Subtle star shimmer near the zenith, gently pulsing.
|
||||
float star = pow(max(sin(rayW.x * 220.0) * sin(rayW.y * 260.0) * sin(rayW.z * 240.0), 0.0), 26.0);
|
||||
star *= smoothstep(0.2, 0.85, h) * (0.55 + 0.45 * sin(time * speed * 2.4));
|
||||
|
||||
vec3 color = base + aurora + vec3(star) * (0.6 + 0.4 * baseColor);
|
||||
|
||||
// Gentle vignette so the effect reads as sky rather than a flat overlay.
|
||||
float vignette = smoothstep(1.35, 0.2, length(sp));
|
||||
color *= mix(0.82, 1.0, vignette);
|
||||
|
||||
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#version 330 core
|
||||
|
||||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Portions of this file are based on, adapted from, or implemented
|
||||
* with reference to a "SkyShader" module distributed for another
|
||||
* Minecraft client. The original attribution was not preserved.
|
||||
* This implementation has since been substantially modified for LoVisual.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
layout (std140) uniform SkyShader {
|
||||
vec4 u_Params0; // x = time, y = speed, z = scale, w = intensity
|
||||
vec4 u_Params1; // x = resWidth, y = resHeight, z = alpha, w = fov
|
||||
vec4 u_Color; // rgb = base color
|
||||
vec4 u_CameraDir; // x = -yawRad, y = pitchRad
|
||||
};
|
||||
|
||||
in vec2 v_TexCoord;
|
||||
|
||||
#define MAX_ITER 4
|
||||
|
||||
mat3 rotX(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3(1.0, 0.0, 0.0,
|
||||
0.0, c, s,
|
||||
0.0, -s, c);
|
||||
}
|
||||
mat3 rotY(float a) {
|
||||
float c = cos(a), s = sin(a);
|
||||
return mat3( c, 0.0, s,
|
||||
0.0, 1.0, 0.0,
|
||||
-s, 0.0, c);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = u_Params0.x;
|
||||
float speed = u_Params0.y;
|
||||
float scale = u_Params0.z;
|
||||
float intensity = u_Params0.w;
|
||||
|
||||
vec2 res = u_Params1.xy;
|
||||
float alpha = u_Params1.z;
|
||||
float fov = u_Params1.w;
|
||||
|
||||
vec3 baseColor = u_Color.rgb;
|
||||
|
||||
vec2 sp = v_TexCoord * 2.0 - 1.0;
|
||||
float aspect = res.x / max(res.y, 1.0);
|
||||
|
||||
float tanV = tan(radians(fov) * 0.5);
|
||||
vec3 rayV = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, 1.0));
|
||||
vec3 rayW = rotY(u_CameraDir.x) * rotX(u_CameraDir.y) * rayV;
|
||||
|
||||
// 3D noise from the world ray direction - seamless on the full sphere.
|
||||
vec3 p = rayW * scale;
|
||||
vec3 i = p;
|
||||
float c = 1.0;
|
||||
float inten = intensity;
|
||||
|
||||
for (int n = 0; n < MAX_ITER; n++) {
|
||||
float t = time * speed * (11.0 - (3.0 / float(n + 1)));
|
||||
i = p + vec3(
|
||||
cos(t - i.x) + sin(t + i.y),
|
||||
sin(t - i.y) + cos(t + i.z),
|
||||
cos(t - i.z) + sin(t + i.x)
|
||||
);
|
||||
c += 1.0 / length(vec3(
|
||||
p.x / (sin(i.x + t) / inten),
|
||||
p.y / (cos(i.y + t) / inten),
|
||||
p.z / (sin(i.z + t) / inten)
|
||||
));
|
||||
}
|
||||
|
||||
c /= float(MAX_ITER);
|
||||
c = 1.5 - sqrt(c);
|
||||
float brightness = c * c * c * c;
|
||||
|
||||
// Softer highlight rolloff and a deeper base tone for a more "liquid" feel.
|
||||
vec3 color = baseColor * pow(brightness, 0.85) * 1.15 + baseColor * 0.18;
|
||||
|
||||
float vignette = smoothstep(1.4, 0.15, length(sp));
|
||||
color *= mix(0.75, 1.0, vignette);
|
||||
|
||||
fragColor = vec4(clamp(color, 0.0, 1.0), alpha);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
#version 330 core
|
||||
|
||||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 pivosos2007.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
out vec4 color;
|
||||
|
||||
uniform sampler2D u_Texture;
|
||||
uniform sampler2D u_MainDepth;
|
||||
|
||||
layout (std140) uniform WetSurface {
|
||||
mat4 u_Projection;
|
||||
mat4 u_InvView; // camera-space -> world-space rotation (camera-to-world)
|
||||
vec4 u_CameraPos; // world-space camera position, w unused
|
||||
vec4 u_Screen; // invW, invH, time, mainDepthEnabled
|
||||
vec4 u_Params; // wetness, puddleCoverage, reflectionStrength, rippleStrength
|
||||
vec4 u_Params2; // maxDistance, reflectionSteps, rainAmount, ripplesEnabled
|
||||
};
|
||||
|
||||
in vec2 v_TexCoord;
|
||||
|
||||
const float DEPTH_NEAR_EPS = 0.000001;
|
||||
const float DEPTH_FAR_EPS = 0.999999;
|
||||
|
||||
bool validRawDepth(float d) {
|
||||
return d == d && d > DEPTH_NEAR_EPS && d < DEPTH_FAR_EPS;
|
||||
}
|
||||
|
||||
vec3 viewPositionFromDepth(vec2 uv, float rawDepth) {
|
||||
vec4 clip = vec4(uv * 2.0 - 1.0, rawDepth, 1.0);
|
||||
vec4 view = inverse(u_Projection) * clip;
|
||||
return view.xyz / view.w;
|
||||
}
|
||||
|
||||
bool projectToScreen(vec3 viewPos, out vec2 uv, out float rawDepth) {
|
||||
vec4 clip = u_Projection * vec4(viewPos, 1.0);
|
||||
if (clip.w <= 0.0001) return false;
|
||||
vec3 ndc = clip.xyz / clip.w;
|
||||
uv = ndc.xy * 0.5 + 0.5;
|
||||
rawDepth = ndc.z;
|
||||
return uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0;
|
||||
}
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float valueNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float puddleNoise(vec2 worldXZ) {
|
||||
float n = valueNoise(worldXZ * 0.18) * 0.65
|
||||
+ valueNoise(worldXZ * 0.45 + 17.0) * 0.35;
|
||||
return n;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_TexCoord;
|
||||
vec4 base = texture(u_Texture, uv);
|
||||
|
||||
if (u_Screen.w < 0.5) {
|
||||
color = base;
|
||||
return;
|
||||
}
|
||||
|
||||
float rawDepth = texture(u_MainDepth, uv).r;
|
||||
if (!validRawDepth(rawDepth)) {
|
||||
color = base;
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 viewPos = viewPositionFromDepth(uv, rawDepth);
|
||||
|
||||
vec3 ddxPos = dFdx(viewPos);
|
||||
vec3 ddyPos = dFdy(viewPos);
|
||||
vec3 viewNormal = normalize(cross(ddxPos, ddyPos));
|
||||
vec3 viewDir = normalize(viewPos);
|
||||
if (dot(viewNormal, -viewDir) < 0.0) {
|
||||
viewNormal = -viewNormal;
|
||||
}
|
||||
|
||||
vec3 worldNormal = normalize(mat3(u_InvView) * viewNormal);
|
||||
float groundFactor = clamp(dot(worldNormal, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
|
||||
groundFactor = smoothstep(0.82, 0.98, groundFactor);
|
||||
if (groundFactor <= 0.001) {
|
||||
color = base;
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 worldPos = u_CameraPos.xyz + (mat3(u_InvView) * viewPos);
|
||||
|
||||
float wetness = clamp(u_Params.x, 0.0, 2.0);
|
||||
float puddleCoverage = clamp(u_Params.y, 0.0, 1.0);
|
||||
float reflectionStrength = clamp(u_Params.z, 0.0, 2.0);
|
||||
float rippleStrength = clamp(u_Params.w, 0.0, 2.0);
|
||||
float maxDistance = max(u_Params2.x, 1.0);
|
||||
int steps = int(clamp(floor(u_Params2.y + 0.5), 1.0, 32.0));
|
||||
float rainAmount = clamp(u_Params2.z, 0.0, 2.0);
|
||||
bool ripplesEnabled = u_Params2.w > 0.5;
|
||||
|
||||
float noise = puddleNoise(worldPos.xz);
|
||||
float wetMask = smoothstep(0.5 - puddleCoverage * 0.55, 0.5 + puddleCoverage * 0.35, noise);
|
||||
wetMask *= groundFactor;
|
||||
if (wetMask <= 0.003) {
|
||||
color = base;
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 reflNormal = viewNormal;
|
||||
if (ripplesEnabled && rippleStrength > 0.001) {
|
||||
float t = u_Screen.z;
|
||||
float ripple = sin((worldPos.x + worldPos.z) * 3.1 + t * 2.4) * 0.5
|
||||
+ sin((worldPos.x - worldPos.z * 1.7) * 4.3 - t * 3.1) * 0.5;
|
||||
vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;
|
||||
reflNormal = normalize(viewNormal + vec3(ripple, 0.0, ripple * 0.6) * rippleStrength * 0.05);
|
||||
}
|
||||
|
||||
vec3 reflDir = reflect(viewDir, reflNormal);
|
||||
|
||||
vec3 reflectionColor = mix(vec3(0.45, 0.52, 0.62), vec3(0.75, 0.82, 0.92), clamp(reflDir.y * 0.5 + 0.5, 0.0, 1.0));
|
||||
bool hit = false;
|
||||
|
||||
float stepLen = maxDistance / float(steps);
|
||||
vec3 marchPos = viewPos + reflNormal * 0.02;
|
||||
for (int i = 0; i < 32; i++) {
|
||||
if (i >= steps) break;
|
||||
|
||||
marchPos += reflDir * stepLen;
|
||||
|
||||
vec2 sampleUv;
|
||||
float sampleClipDepth;
|
||||
if (!projectToScreen(marchPos, sampleUv, sampleClipDepth)) break;
|
||||
|
||||
float sceneRawDepth = texture(u_MainDepth, sampleUv).r;
|
||||
if (!validRawDepth(sceneRawDepth)) continue;
|
||||
|
||||
vec3 scenePos = viewPositionFromDepth(sampleUv, sceneRawDepth);
|
||||
|
||||
float marchDist = length(marchPos);
|
||||
float sceneDist = length(scenePos);
|
||||
float thickness = max(0.15, marchDist * 0.03);
|
||||
|
||||
if (marchDist > sceneDist && marchDist - sceneDist < thickness) {
|
||||
reflectionColor = texture(u_Texture, sampleUv).rgb;
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
float fresnel = pow(1.0 - clamp(dot(-viewDir, viewNormal), 0.0, 1.0), 3.0);
|
||||
fresnel = mix(0.25, 1.0, fresnel);
|
||||
|
||||
float hitBoost = hit ? 1.0 : 0.55;
|
||||
float reflectionAmount = wetMask * reflectionStrength * fresnel * hitBoost
|
||||
* mix(0.6, 1.0, clamp(rainAmount, 0.0, 1.0)) * wetness;
|
||||
reflectionAmount = clamp(reflectionAmount, 0.0, 1.0);
|
||||
|
||||
vec3 darkened = base.rgb * mix(1.0, 0.82, wetMask * clamp(wetness, 0.0, 1.0));
|
||||
vec3 result = mix(darkened, reflectionColor, reflectionAmount);
|
||||
|
||||
color = vec4(clamp(result, 0.0, 1.0), 1.0);
|
||||
}
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -177,8 +177,7 @@
|
|||
"sodium.SodiumVertexConsumerTrackerMixin",
|
||||
"sodium.SodiumWorldRendererFreecamMixin",
|
||||
"xaero.minimap.AbstractWaypointRenderProviderMixin",
|
||||
"xaero.worldmap.WaypointRenderProviderMixin",
|
||||
"xaero.worldmap.WorldMapWaypointAccessor"
|
||||
"xaero.worldmap.WaypointRenderProviderMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"defaultRequire": 1
|
||||
|
|
|
|||