diff --git a/docs/tasks.md b/docs/tasks.md index e3125871..c86f5724 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -40,7 +40,7 @@ FPSMaster v4将会在此列表任务大部分完成后发布 - [ ] 添加异步光照性能优化 - [ ] 添加自定义迷雾(颜色,距离等) - [ ] 为客户端的组件颜色选择添加彩色选项 -- [ ] 在聊天栏显示头像 +- [x] 在聊天栏显示头像 - [ ] HypixelMod(合并AutoGG,AutoPlay以及聊天栏实用功能等) - [ ] 添加其他版本的支持(1.21.4 等) - [ ] 允许将正版账号绑定到FPSMaster账号上,并通过正版验证自动登录 diff --git a/src/main/java/top/fpsmaster/event/EventDispatcher.java b/src/main/java/top/fpsmaster/event/EventDispatcher.java index f3a48d83..9b1e9782 100644 --- a/src/main/java/top/fpsmaster/event/EventDispatcher.java +++ b/src/main/java/top/fpsmaster/event/EventDispatcher.java @@ -21,7 +21,7 @@ public static void registerListener(Object listener) { if (Event.class.isAssignableFrom(parameterType)) { Class eventType = (Class) parameterType; List listeners = eventListeners.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>()); - listeners.add(new ReflectHandler(listener, method)); + listeners.add(new MethodHandleHandler(listener, method)); } } } diff --git a/src/main/java/top/fpsmaster/event/MethodHandleHandler.java b/src/main/java/top/fpsmaster/event/MethodHandleHandler.java new file mode 100644 index 00000000..e22b514f --- /dev/null +++ b/src/main/java/top/fpsmaster/event/MethodHandleHandler.java @@ -0,0 +1,43 @@ +package top.fpsmaster.event; + +import top.fpsmaster.modules.logger.ClientLogger; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; + +public class MethodHandleHandler extends Handler { + private final MethodHandle handle; + + public MethodHandleHandler(Object listener, Method method) { + super(listener, method); + try { + method.setAccessible(true); + this.handle = MethodHandles.lookup() + .unreflect(method) + .bindTo(listener) + .asType(MethodType.methodType(void.class, Event.class)); + } catch (IllegalAccessException exception) { + throw new RuntimeException("Failed to create event handler for " + getLog(), exception); + } + } + + @Override + public void invoke(Event event) { + try { + handle.invokeExact(event); + } catch (Throwable throwable) { + ClientLogger.error("Error when invoking event " + getLog()); + if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } + throw new RuntimeException(throwable); + } + } + + @Override + public String getLog() { + return listener.getClass().getSimpleName() + " -> " + method.getName(); + } +} diff --git a/src/main/java/top/fpsmaster/features/GlobalListener.java b/src/main/java/top/fpsmaster/features/GlobalListener.java index 6cb2e616..30c1517f 100644 --- a/src/main/java/top/fpsmaster/features/GlobalListener.java +++ b/src/main/java/top/fpsmaster/features/GlobalListener.java @@ -78,7 +78,7 @@ public void onRender(EventRender2D e) { if (ClientSettings.blur.getValue()) { StencilUtil.initStencilToWrite(); EventDispatcher.dispatchEvent(new EventShader()); - FPSMaster.componentsManager.draw((int) mouseX, (int) mouseY); + FPSMaster.componentsManager.drawBackgroundMasks(); StencilUtil.readStencilBuffer(1); KawaseBlur.renderBlur(3, 3); StencilUtil.uninitStencilBuffer(); diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java new file mode 100644 index 00000000..d75439e4 --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatarCache.java @@ -0,0 +1,488 @@ +package top.fpsmaster.features.impl.render; + +import com.mojang.authlib.GameProfile; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.AbstractClientPlayer; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.network.NetworkPlayerInfo; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.event.ClickEvent; +import net.minecraft.util.IChatComponent; +import net.minecraft.util.ResourceLocation; +import org.lwjgl.opengl.GL11; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static top.fpsmaster.utils.core.Utility.mc; + +public class ChatAvatarCache { + private static final int MAX_CACHE_SIZE = 256; + private static final long IN_GAME_TTL_MS = 30_000L; + private static final long MISS_TTL_MS = 2L * 60L * 1000L; + private static final int MAX_SENDER_SEPARATOR_DISTANCE = 16; + private static final String[] WHISPER_COMMAND_PREFIXES = {"/tell ", "/msg ", "/whisper ", "/w ", "/t "}; + private static final Pattern TIMESTAMP_PREFIX = + Pattern.compile("^(?:\\[\\d\\d:\\d\\d(?::\\d\\d)?(?: [AP]M)?]|<\\d\\d:\\d\\d>)\\s*"); + + // Every texture here is owned by the vanilla skin manager (tab list / world entities), so the + // cache never allocates or frees GL textures of its own — eviction is a plain map removal. + private static final LinkedHashMap CACHE = new LinkedHashMap(32, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + + private static final LinkedHashMap SENDER_CACHE = new LinkedHashMap(32, 0.75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + + private enum State { + READY, + MISS + } + + private static class AvatarEntry { + private State state; + private ResourceLocation texture; + private long expireAt; + + private AvatarEntry(State state, ResourceLocation texture, long expireAt) { + this.state = state; + this.texture = texture; + this.expireAt = expireAt; + } + } + + private static class SenderEntry { + private String playerName; + private ResourceLocation texture; + private long expireAt; + + private SenderEntry(String playerName, ResourceLocation texture, long expireAt) { + this.playerName = playerName; + this.texture = texture; + this.expireAt = expireAt; + } + } + + private static class PlayerCandidate { + private String realName; + private String matchName; + private ResourceLocation texture; + + private PlayerCandidate(String realName, String matchName, ResourceLocation texture) { + this.realName = realName; + this.matchName = matchName; + this.texture = texture; + } + } + + public static ResourceLocation getAvatar(IChatComponent chatComponent) { + SenderEntry sender = findSender(chatComponent); + if (sender == null || sender.playerName == null || sender.playerName.trim().isEmpty()) { + return null; + } + String playerName = sender.playerName; + long now = System.currentTimeMillis(); + AvatarEntry entry = get(playerName); + if (sender.texture != null) { + if (entry == null || entry.state != State.READY || entry.texture != sender.texture || entry.expireAt <= now) { + put(playerName, new AvatarEntry(State.READY, sender.texture, now + IN_GAME_TTL_MS)); + } + return sender.texture; + } + + if (entry != null && entry.expireAt > now) { + return entry.state == State.READY ? entry.texture : null; + } + + ResourceLocation inGameSkin = getInGameSkin(playerName); + if (inGameSkin != null) { + put(playerName, new AvatarEntry(State.READY, inGameSkin, now + IN_GAME_TTL_MS)); + return inGameSkin; + } + + // Only players the vanilla skin manager already knows about get a head; an unresolved name + // is cached as a miss rather than fetched, so chat rendering never touches the network. + put(playerName, new AvatarEntry(State.MISS, null, now + MISS_TTL_MS)); + return null; + } + + public static void drawHead(ResourceLocation skin, int x, int y, int size, int alpha) { + if (skin == null || alpha <= 3) { + return; + } + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null) { + return; + } + + boolean depthEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST); + GlStateManager.disableDepth(); + GlStateManager.enableBlend(); + GlStateManager.tryBlendFuncSeparate(770, 771, 1, 0); + GlStateManager.color(1.0F, 1.0F, 1.0F, Math.max(0.0F, Math.min(1.0F, alpha / 255.0F))); + minecraft.getTextureManager().bindTexture(skin); + Gui.drawScaledCustomSizeModalRect(x, y, 8.0F, 8.0F, 8, 8, size, size, 64.0F, 64.0F); + Gui.drawScaledCustomSizeModalRect(x, y, 40.0F, 8.0F, 8, 8, size, size, 64.0F, 64.0F); + GlStateManager.color(1.0F, 1.0F, 1.0F, 1.0F); + if (depthEnabled) { + GlStateManager.enableDepth(); + } else { + GlStateManager.disableDepth(); + } + } + + private static AvatarEntry get(String playerName) { + synchronized (CACHE) { + return CACHE.get(cacheKey(playerName)); + } + } + + private static void put(String playerName, AvatarEntry entry) { + synchronized (CACHE) { + CACHE.put(cacheKey(playerName), entry); + } + } + + private static SenderEntry findSender(IChatComponent chatComponent) { + if (chatComponent == null) { + return null; + } + String text = chatComponent.getUnformattedText(); + if (text == null || text.trim().isEmpty()) { + return null; + } + String cacheKey = senderCacheKey(text); + long now = System.currentTimeMillis(); + synchronized (SENDER_CACHE) { + SenderEntry entry = SENDER_CACHE.get(cacheKey); + if (entry != null && entry.expireAt > now) { + return entry; + } + } + + // Highest-confidence signal first: vanilla and most chat plugins attach a "/msg " + // suggest-command to the sender's name specifically, which beats any text heuristic. + PlayerCandidate player = findByWhisperCommand(chatComponent); + if (player == null) { + player = findOnlinePlayer(text); + } + SenderEntry sender = player != null + ? new SenderEntry(player.realName, player.texture, now + 1_000L) + : new SenderEntry(extractLikelySenderName(text), null, now + 1_000L); + synchronized (SENDER_CACHE) { + SENDER_CACHE.put(cacheKey, sender); + } + return sender; + } + + /** + * Resolves the sender from a whisper click event rather than from the rendered text. Vanilla's chat + * decorator — and most server chat plugins — hang a {@code /msg } suggest-command on the + * sender's name component, so when it is present it identifies the sender exactly. + */ + private static PlayerCandidate findByWhisperCommand(IChatComponent chatComponent) { + String name = findWhisperTarget(chatComponent); + if (name == null) { + return null; + } + return new PlayerCandidate(name, name, getInGameSkin(name)); + } + + private static String findWhisperTarget(IChatComponent chatComponent) { + try { + // IChatComponent iterates itself plus all nested siblings. + for (IChatComponent part : chatComponent) { + if (part == null || part.getChatStyle() == null) { + continue; + } + String name = whisperTargetOf(part.getChatStyle().getChatClickEvent()); + if (name != null) { + return name; + } + } + } catch (Exception ignored) { + // Malformed component trees from servers must not break chat rendering. + } + return null; + } + + private static String whisperTargetOf(ClickEvent click) { + if (click == null || click.getValue() == null) { + return null; + } + if (click.getAction() != ClickEvent.Action.SUGGEST_COMMAND + && click.getAction() != ClickEvent.Action.RUN_COMMAND) { + return null; + } + String command = click.getValue().trim(); + for (String prefix : WHISPER_COMMAND_PREFIXES) { + if (!command.regionMatches(true, 0, prefix, 0, prefix.length())) { + continue; + } + String rest = command.substring(prefix.length()).trim(); + int space = rest.indexOf(' '); + if (space >= 0) { + rest = rest.substring(0, space); + } + return isValidPlayerName(rest) ? rest : null; + } + return null; + } + + private static PlayerCandidate findOnlinePlayer(String text) { + List candidates = getOnlinePlayers(); + if (candidates.isEmpty()) { + return null; + } + + String head = stripTimestamp(text); + head = head.substring(0, Math.min(head.length(), 96)); + + // A name immediately followed by a chat delimiter is the sender. Prefer the rightmost such + // match so rank/guild prefixes ("[Guild] MVP Player > hi") don't win over the real name. + PlayerCandidate best = findBestOnlinePlayer(head, candidates, true, true); + if (best != null) { + return best; + } + // No delimiter anywhere means a system line ("Player joined the game"). Here the subject + // comes first, so take the leftmost match — rightmost would pick "Alice" out of + // "Bob was slain by Alice". + return firstDelimiter(head) < 0 ? findBestOnlinePlayer(head, candidates, false, false) : null; + } + + private static PlayerCandidate findBestOnlinePlayer(String text, List candidates, + boolean requireChatSeparator, boolean preferRightmost) { + PlayerCandidate best = null; + int bestIndex = -1; + int bestLength = -1; + for (PlayerCandidate candidate : candidates) { + if (candidate == null || candidate.matchName == null || candidate.matchName.isEmpty()) { + continue; + } + int index = indexOfSenderName(text, candidate.matchName, requireChatSeparator, preferRightmost); + if (index < 0) { + continue; + } + int length = candidate.matchName.length(); + boolean better = best == null + || (preferRightmost ? index > bestIndex : index < bestIndex) + || (index == bestIndex && length > bestLength); + if (better) { + bestIndex = index; + bestLength = length; + best = candidate; + } + } + return best; + } + + private static List getOnlinePlayers() { + ArrayList candidates = new ArrayList<>(); + try { + if (mc != null && mc.getNetHandler() != null) { + Collection infos = mc.getNetHandler().getPlayerInfoMap(); + if (infos != null) { + for (NetworkPlayerInfo info : infos) { + if (info == null || info.getGameProfile() == null || info.getGameProfile().getName() == null) { + continue; + } + String realName = info.getGameProfile().getName(); + addCandidate(candidates, realName, realName, info.getLocationSkin()); + if (info.getDisplayName() != null) { + addCandidate(candidates, realName, info.getDisplayName().getUnformattedText(), info.getLocationSkin()); + } + } + } + } + } catch (Exception ignored) { + // Net handler data can disappear while changing servers. + } + + try { + if (mc != null && mc.theWorld != null && mc.theWorld.playerEntities != null) { + for (Object object : mc.theWorld.playerEntities) { + if (object instanceof EntityPlayer) { + EntityPlayer player = (EntityPlayer) object; + String realName = player.getName(); + ResourceLocation texture = player instanceof AbstractClientPlayer ? ((AbstractClientPlayer) player).getLocationSkin() : null; + addCandidate(candidates, realName, realName, texture); + if (player.getDisplayName() != null) { + addCandidate(candidates, realName, player.getDisplayName().getUnformattedText(), texture); + } + } + } + } + } catch (Exception ignored) { + // World player list can be mutated by the client thread during shutdown. + } + return candidates; + } + + private static void addCandidate(List candidates, String realName, String matchName, ResourceLocation texture) { + if (realName == null || matchName == null || matchName.trim().isEmpty()) { + return; + } + String trimmed = matchName.trim(); + for (PlayerCandidate candidate : candidates) { + if (candidate.realName.equalsIgnoreCase(realName) && candidate.matchName.equalsIgnoreCase(trimmed)) { + return; + } + } + candidates.add(new PlayerCandidate(realName, trimmed, texture)); + } + + private static int indexOfSenderName(String text, String playerName, boolean requireChatSeparator, boolean preferRightmost) { + String lowerText = text.toLowerCase(Locale.ROOT); + String lowerName = playerName.toLowerCase(Locale.ROOT); + int best = -1; + int from = 0; + while (from < lowerText.length()) { + int index = lowerText.indexOf(lowerName, from); + if (index < 0) { + break; + } + int before = index - 1; + int after = index + lowerName.length(); + // Word-boundary guards on both sides: "tom" must not match inside "custom" or "tomato". + boolean beforeOk = before < 0 || !isWordChar(lowerText.charAt(before)); + boolean afterOk = after >= lowerText.length() || !isWordChar(lowerText.charAt(after)); + if (beforeOk && afterOk && (!requireChatSeparator || hasChatSeparatorAfter(text, after))) { + best = index; + if (!preferRightmost) { + return best; + } + } + from = index + 1; + } + return best; + } + + private static boolean hasChatSeparatorAfter(String text, int from) { + int limit = Math.min(text.length(), from + MAX_SENDER_SEPARATOR_DISTANCE); + for (int i = from; i < limit; i++) { + if (isChatDelimiter(text.charAt(i))) { + return true; + } + } + return false; + } + + /** + * Strips a leading clock stamp that another chat mod may have prepended, e.g. {@code [12:34] } or + * {@code <12:34> }. Without this the sender name is no longer at the head of the line and the + * delimiter-anchored match degrades. + */ + private static String stripTimestamp(String text) { + Matcher matcher = TIMESTAMP_PREFIX.matcher(text); + return matcher.lookingAt() ? text.substring(matcher.end()) : text; + } + + private static String extractLikelySenderName(String text) { + int delimiter = firstDelimiter(text); + if (delimiter < 0) { + return null; + } + String prefix = text.substring(0, Math.min(delimiter, 96)); + String[] tokens = prefix.split("[^A-Za-z0-9_]+"); + for (int i = tokens.length - 1; i >= 0; i--) { + String token = tokens[i]; + if (isValidPlayerName(token)) { + return token; + } + } + return null; + } + + private static int firstDelimiter(String text) { + for (int i = 0; i < text.length(); i++) { + if (isChatDelimiter(text.charAt(i))) { + return i; + } + } + return -1; + } + + private static boolean isChatDelimiter(char character) { + return character == ':' || character == ':' || character == '>' || character == '»' || character == '›'; + } + + /** Minecraft account-name alphabet — deliberately ASCII-only, used to validate a real username. */ + private static boolean isNameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + } + + /** + * Word-boundary alphabet for match guarding. Unlike {@link #isNameChar} this is Unicode-aware, so a + * CJK display name sitting flush against other CJK text is not mistaken for a standalone token. + */ + private static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } + + private static boolean isValidPlayerName(String name) { + if (name == null || name.length() < 3 || name.length() > 16) { + return false; + } + for (int i = 0; i < name.length(); i++) { + if (!isNameChar(name.charAt(i))) { + return false; + } + } + return true; + } + + private static ResourceLocation getInGameSkin(String playerName) { + try { + if (mc != null && mc.getNetHandler() != null) { + Collection infos = mc.getNetHandler().getPlayerInfoMap(); + if (infos != null) { + for (NetworkPlayerInfo info : infos) { + if (info == null || info.getGameProfile() == null) { + continue; + } + GameProfile profile = info.getGameProfile(); + if (profile.getName() != null && profile.getName().equalsIgnoreCase(playerName)) { + return info.getLocationSkin(); + } + } + } + } + } catch (Exception ignored) { + // Fall through to world lookup. + } + + try { + if (mc != null && mc.theWorld != null) { + EntityPlayer player = mc.theWorld.getPlayerEntityByName(playerName); + if (player instanceof AbstractClientPlayer) { + return ((AbstractClientPlayer) player).getLocationSkin(); + } + } + } catch (Exception ignored) { + // Runtime player objects can be null while changing worlds. + } + return null; + } + + private static String cacheKey(String playerName) { + return playerName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_]", "_"); + } + + private static String senderCacheKey(String text) { + return text.substring(0, Math.min(text.length(), 96)).toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java new file mode 100644 index 00000000..e9236e6a --- /dev/null +++ b/src/main/java/top/fpsmaster/features/impl/render/ChatAvatars.java @@ -0,0 +1,63 @@ +package top.fpsmaster.features.impl.render; + +import top.fpsmaster.features.manager.Category; +import top.fpsmaster.features.manager.Module; +import top.fpsmaster.features.settings.impl.NumberSetting; +import net.minecraft.util.IChatComponent; +import net.minecraft.util.ResourceLocation; + +public class ChatAvatars extends Module { + public static boolean using = false; + + public static final NumberSetting size = new NumberSetting("Size", 8, 6, 9, 1); + public static final NumberSetting gap = new NumberSetting("Gap", 4, 1, 8, 1); + public static final NumberSetting offsetX = new NumberSetting("OffsetX", 0, -8, 8, 1); + public static final NumberSetting offsetY = new NumberSetting("OffsetY", 1, -4, 6, 1); + + public ChatAvatars() { + super("ChatAvatars", Category.RENDER); + addSettings(size, gap, offsetX, offsetY); + } + + @Override + public void onEnable() { + super.onEnable(); + using = true; + } + + @Override + public void onDisable() { + super.onDisable(); + using = false; + } + + public static boolean isUsing() { + return using; + } + + public static int getAvatarSize() { + return Math.max(6, Math.min(9, size.getValue().intValue())); + } + + public static int getChatOffset() { + if (!isUsing()) { + return 0; + } + return getAvatarSize() + Math.max(1, Math.min(8, gap.getValue().intValue())); + } + + public static int getOffsetX() { + return offsetX.getValue().intValue(); + } + + public static int getOffsetY() { + return offsetY.getValue().intValue(); + } + + public static ResourceLocation getAvatar(IChatComponent chatComponent) { + if (!isUsing()) { + return null; + } + return ChatAvatarCache.getAvatar(chatComponent); + } +} diff --git a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java index 72263f95..3d129c09 100644 --- a/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java +++ b/src/main/java/top/fpsmaster/features/impl/render/MotionBlur.java @@ -175,62 +175,75 @@ public void onDisable() { public static void blur(float multiplier) { if (OpenGlHelper.isFramebufferEnabled()) { - ScaledResolution sr = new ScaledResolution(Minecraft.getMinecraft()); - int width = Minecraft.getMinecraft().getFramebuffer().framebufferWidth; - int height = Minecraft.getMinecraft().getFramebuffer().framebufferHeight; - // float division: with integer division the quads come up short by the - // remainder pixels, leaving unprocessed rows/columns at the bottom/right edge - float scaledWidth = (float) width / sr.getScaleFactor(); - float scaledHeight = (float) height / sr.getScaleFactor(); - GlStateManager.matrixMode(GL11.GL_PROJECTION); GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.ortho(0.0, scaledWidth, scaledHeight, 0.0, 2000.0, 4000.0); GlStateManager.matrixMode(GL11.GL_MODELVIEW); GlStateManager.pushMatrix(); - GlStateManager.loadIdentity(); - GlStateManager.translate(0f, 0f, -2000f); - - blurBufferMain = checkFramebufferSizes(blurBufferMain, width, height); - blurBufferInto = checkFramebufferSizes(blurBufferInto, width, height); - - blurBufferInto.framebufferClear(); - blurBufferInto.bindFramebuffer(true); - - OpenGlHelper.glBlendFunc(770, 771, 0, 1); // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA - GlStateManager.disableLighting(); - GlStateManager.disableFog(); - GlStateManager.disableBlend(); - - Minecraft.getMinecraft().getFramebuffer().bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, 1f); - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 0f, 1f, 9728); - - GlStateManager.enableBlend(); - blurBufferMain.bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, multiplier / 10 - 0.1f); - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 1f, 0f, 9728); - - Minecraft.getMinecraft().getFramebuffer().bindFramebuffer(true); - blurBufferInto.bindFramebufferTexture(); - GlStateManager.color(1f, 1f, 1f, 1f); - GlStateManager.enableBlend(); - OpenGlHelper.glBlendFunc(770, 771, 1, 771); - - drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, - 0f, 1f, 0f, 1f, 9728); - - Framebuffer tempBuff = blurBufferMain; - blurBufferMain = blurBufferInto; - blurBufferInto = tempBuff; - - GlStateManager.matrixMode(GL11.GL_PROJECTION); - GlStateManager.popMatrix(); - GlStateManager.matrixMode(GL11.GL_MODELVIEW); - GlStateManager.popMatrix(); + try { + ScaledResolution sr = new ScaledResolution(Minecraft.getMinecraft()); + int width = Minecraft.getMinecraft().getFramebuffer().framebufferWidth; + int height = Minecraft.getMinecraft().getFramebuffer().framebufferHeight; + // float division: with integer division the quads come up short by the + // remainder pixels, leaving unprocessed rows/columns at the bottom/right edge + float scaledWidth = (float) width / sr.getScaleFactor(); + float scaledHeight = (float) height / sr.getScaleFactor(); + + GlStateManager.matrixMode(GL11.GL_PROJECTION); + GlStateManager.loadIdentity(); + GlStateManager.ortho(0.0, scaledWidth, scaledHeight, 0.0, 2000.0, 4000.0); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.loadIdentity(); + GlStateManager.translate(0f, 0f, -2000f); + + blurBufferMain = checkFramebufferSizes(blurBufferMain, width, height); + blurBufferInto = checkFramebufferSizes(blurBufferInto, width, height); + + blurBufferInto.framebufferClear(); + blurBufferInto.bindFramebuffer(true); + + OpenGlHelper.glBlendFunc(770, 771, 0, 1); // GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA + GlStateManager.disableLighting(); + GlStateManager.disableFog(); + GlStateManager.disableBlend(); + + Minecraft.getMinecraft().getFramebuffer().bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, 1f); + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 0f, 1f, 9728); + + GlStateManager.enableBlend(); + blurBufferMain.bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, multiplier / 10 - 0.1f); + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 1f, 0f, 9728); + + Minecraft.getMinecraft().getFramebuffer().bindFramebuffer(true); + blurBufferInto.bindFramebufferTexture(); + GlStateManager.color(1f, 1f, 1f, 1f); + GlStateManager.enableBlend(); + OpenGlHelper.glBlendFunc(770, 771, 1, 771); + + drawTexturedRectNoBlend(0f, 0f, scaledWidth, scaledHeight, + 0f, 1f, 0f, 1f, 9728); + + Framebuffer tempBuff = blurBufferMain; + blurBufferMain = blurBufferInto; + blurBufferInto = tempBuff; + } finally { + GlStateManager.matrixMode(GL11.GL_PROJECTION); + GlStateManager.popMatrix(); + GlStateManager.matrixMode(GL11.GL_MODELVIEW); + GlStateManager.popMatrix(); + + // OpenGlHelper.glBlendFunc and bindFramebufferTexture write raw GL, bypassing + // GlStateManager's cache. Push the equivalent state back *through* GlStateManager so + // cache and driver agree. Using glPopAttrib here instead would revert the driver + // behind the cache, silently turning the next enableBlend()/color() into a no-op. + GlStateManager.tryBlendFuncSeparate(770, 771, 1, 0); + GlStateManager.enableBlend(); + GlStateManager.color(1f, 1f, 1f, 1f); + GlStateManager.bindTexture(0); + } } } } diff --git a/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java b/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java index f60c85c1..bc217d33 100644 --- a/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java +++ b/src/main/java/top/fpsmaster/features/impl/utility/LevelTag.java @@ -21,6 +21,8 @@ import static top.fpsmaster.utils.core.Utility.mc; public class LevelTag extends Module { + private static final ResourceLocation MATE_ICON = new ResourceLocation("client/textures/mate.png"); + public static final BooleanSetting showSelf = new BooleanSetting("ShowSelf", true); public static final BooleanSetting betterFont = new BooleanSetting("BetterFont", false); public static final BooleanSetting fontShadow = new BooleanSetting("FontShadow", true); @@ -97,46 +99,32 @@ public static void renderName(Entity entityIn, String str, double x, double y, d if (bg.getValue()) { GlStateManager.disableTexture2D(); + Color color = backgroundColor.getColor(); + float red = color.getRed() / 255f; + float green = color.getGreen() / 255f; + float blue = color.getBlue() / 255f; + float alpha = color.getAlpha() / 255f; + worldRenderer.begin(7, DefaultVertexFormats.POSITION_COLOR); worldRenderer .pos(backgroundLeft, -1 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundLeft, 8 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundRight, 8 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); worldRenderer .pos(backgroundRight, -1 + i, 0.0F) - .color( - backgroundColor.getColor().getRed() / 255f, - backgroundColor.getColor().getGreen() / 255f, - backgroundColor.getColor().getBlue() / 255f, - backgroundColor.getColor().getAlpha() / 255f - ) + .color(red, green, blue, alpha) .endVertex(); tessellator.draw(); @@ -150,7 +138,7 @@ public static void renderName(Entity entityIn, String str, double x, double y, d */ if (isMate) { Images.draw( - new ResourceLocation("client/textures/mate.png"), + MATE_ICON, iconX, i - 1, 8, @@ -158,39 +146,8 @@ public static void renderName(Entity entityIn, String str, double x, double y, d -1, true ); - - if (fontShadow.getValue()) { - fontRenderer.drawStringWithShadow( - str, - textX, - i, - 553648127 - ); - } else { - fontRenderer.drawString( - str, - textX, - i, - 553648127 - ); - } - } else { - if (fontShadow.getValue()) { - fontRenderer.drawStringWithShadow( - str, - textX, - i, - 553648127 - ); - } else { - fontRenderer.drawString( - str, - textX, - i, - 553648127 - ); - } } + fontRenderer.drawString(str, textX, i, 553648127); /* * 第二遍绘制:开启深度后绘制正常白色文字。 diff --git a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java index ae5b47a2..11ae43b6 100644 --- a/src/main/java/top/fpsmaster/features/manager/ModuleManager.java +++ b/src/main/java/top/fpsmaster/features/manager/ModuleManager.java @@ -90,6 +90,7 @@ public void init() { modules.add(new ItemPhysics()); modules.add(new MinimizedBobbing()); modules.add(new MoreParticles()); + modules.add(new ChatAvatars()); modules.add(new FPSDisplay()); modules.add(new ArmorDisplay()); modules.add(new BetterChat()); diff --git a/src/main/java/top/fpsmaster/font/impl/StringCache.java b/src/main/java/top/fpsmaster/font/impl/StringCache.java index ecc76a77..5bbeb506 100644 --- a/src/main/java/top/fpsmaster/font/impl/StringCache.java +++ b/src/main/java/top/fpsmaster/font/impl/StringCache.java @@ -427,7 +427,7 @@ public int renderString(String str, float startX, float startY, int initialColor GlStateManager.disableBlend(); - // 文字位置像素对齐,避免纹理采样模糊 + // 对齐两个绘制 pass,避免粗体字形在线性过滤下因半像素阴影产生模糊光晕。 startX = Math.round(startX); startY = Math.round(startY); diff --git a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java index d6ea8226..a0b793a7 100644 --- a/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java +++ b/src/main/java/top/fpsmaster/font/impl/UFontRenderer.java @@ -90,15 +90,16 @@ public String trimStringToWidth(String text, float width) { return trimString(text, width, false); } + /** + * Deliberately does not run {@link GlobalTextFilter}: callers such as {@code TextField} use + * the returned length as an index back into the original, unfiltered string, and NameProtect's + * substitution changes the length. Filtering belongs to the draw path only. + */ public String trimString(String text, float width, boolean reverse) { - StringBuilder stringbuilder = new StringBuilder(); - for (char c : text.toCharArray()) { - if (getStringWidth(stringbuilder.toString()) < width) - stringbuilder.append(c); - else - break; + if (text == null || text.isEmpty()) { + return ""; } - return stringbuilder.toString(); + return stringCache.trimStringToWidth(text, width, reverse); } /** @@ -161,7 +162,11 @@ private int drawHighDensityString(String text, float x, float y, int color, bool GL11.glPushMatrix(); GL11.glScalef(inverseScale, inverseScale, 1.0f); try { - int result = renderer.drawStringInternal(text, x * actualDensityScale, y * actualDensityScale, color, dropShadow, actualDensityScale * 0.5f); + // Offset is expressed in the density renderer's device space, so scale the 1px GUI-space + // shadow by the same factor as the coordinates. Passing half of this (the old value) came + // back as a 0.5px GUI shadow after the inverse scale, i.e. the very offset that pixel + // snapping in StringCache.renderString was changed to avoid. + int result = renderer.drawStringInternal(text, x * actualDensityScale, y * actualDensityScale, color, dropShadow, actualDensityScale); return Math.round(result * inverseScale); } finally { GL11.glPopMatrix(); @@ -169,7 +174,7 @@ private int drawHighDensityString(String text, float x, float y, int color, bool } private int drawStringInternal(String text, float x, float y, int color, boolean dropShadow) { - return drawStringInternal(text, x, y, color, dropShadow, 0.5f); + return drawStringInternal(text, x, y, color, dropShadow, 1.0f); } private int drawStringInternal(String text, float x, float y, int color, boolean dropShadow, float shadowOffset) { diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java index f4b72112..61d0753b 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinGuiNewChat.java @@ -8,15 +8,21 @@ import net.minecraft.util.ChatComponentText; import net.minecraft.util.IChatComponent; import net.minecraft.util.MathHelper; +import net.minecraft.util.ResourceLocation; import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; import top.fpsmaster.FPSMaster; import top.fpsmaster.features.impl.interfaces.BetterChat; +import top.fpsmaster.features.impl.render.ChatAvatarCache; +import top.fpsmaster.features.impl.render.ChatAvatars; import java.awt.*; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.IdentityHashMap; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.Map; import static top.fpsmaster.utils.core.Utility.mc; @@ -51,6 +57,17 @@ public abstract class MixinGuiNewChat { @Shadow @Final private List chatLines; + @Unique + private static final int FPSMASTER_MAX_CHAT_MESSAGE_SOURCES = 512; + + // Identity-keyed on purpose: IChatComponent.hashCode walks the whole sibling tree and is not safe + // to call on arbitrary server-supplied components. + @Unique + private final Map fpsmaster$chatMessageSources = new IdentityHashMap<>(); + + @Unique + private final Deque fpsmaster$chatMessageOrder = new ArrayDeque<>(); + /** * @author SuperSkidder * @reason betterchat @@ -67,8 +84,9 @@ public void drawChat(int updateCounter) { float g = this.getChatScale(); int k = MathHelper.ceiling_float_int((float) this.getChatWidth() / g); + int chatAvatarOffset = ChatAvatars.getChatOffset(); GlStateManager.pushMatrix(); - GlStateManager.translate(2.0F, 8.0F, 0.0F); + GlStateManager.translate(2.0F + chatAvatarOffset, 8.0F, 0.0F); GlStateManager.scale(g, g, 1.0F); int l = 0; @@ -94,7 +112,10 @@ public void drawChat(int updateCounter) { ++l; if (o > 3) { int q = -m * 9; - Gui.drawRect(-2, q - 9, k + 4, q, o / 2 << 24); + // Right edge also shifts back by the gutter, otherwise the whole + // panel grows wider than the configured chat width. + Gui.drawRect(-2 - chatAvatarOffset, q - 9, k + 4 - chatAvatarOffset, q, o / 2 << 24); + drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY(), o); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); mc.fontRendererObj.drawStringWithShadow(string, 0.0F, (float) (q - 8), 16777215 + (o << 24)); @@ -115,8 +136,8 @@ public void drawChat(int updateCounter) { if (r != n) { o = s > 0 ? 170 : 96; int p = this.isScrolled ? 13382451 : 3355562; - Gui.drawRect(0, -s, 2, -s - t, p + (o << 24)); - Gui.drawRect(2, -s, 1, -s - t, 13421772 + (o << 24)); + Gui.drawRect(-chatAvatarOffset, -s, 2 - chatAvatarOffset, -s - t, p + (o << 24)); + Gui.drawRect(2 - chatAvatarOffset, -s, 1 - chatAvatarOffset, -s - t, 13421772 + (o << 24)); } } @@ -124,7 +145,7 @@ public void drawChat(int updateCounter) { } } else { BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); - AtomicInteger i = new AtomicInteger(this.getLineCount()); + int i = this.getLineCount(); int j = drawnChatLines.size(); float f = mc.gameSettings.chatOpacity * 0.9F + 0.1F; if (j > 0) { @@ -132,14 +153,15 @@ public void drawChat(int updateCounter) { float g = this.getChatScale(); int k = MathHelper.ceiling_float_int((float) this.getChatWidth() / g); + int chatAvatarOffset = ChatAvatars.getChatOffset(); GlStateManager.pushMatrix(); - GlStateManager.translate(2.0F, 8.0F, 0.0F); + GlStateManager.translate(2.0F + chatAvatarOffset, 8.0F, 0.0F); GlStateManager.scale(g, g, 1.0F); int m; int n; int o; - for (m = 0; m + this.scrollPos < drawnChatLines.size() && m < i.get(); ++m) { + for (m = 0; m + this.scrollPos < drawnChatLines.size() && m < i; ++m) { ChatLine chatLine = drawnChatLines.get(m + this.scrollPos); if (chatLine != null) { if (getChatOpen() && v1_8_9$isChatOpenAnimationNeed) { @@ -156,7 +178,8 @@ public void drawChat(int updateCounter) { if (alpha > 3) { int q = -m * 9; int alpha1 = (int) ((alpha / 255f) * module.backgroundColor.getColor().getAlpha()); - Gui.drawRect(-2, q - 8, k + 4, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); + Gui.drawRect(-2 - chatAvatarOffset, q - 8, k + 4 - chatAvatarOffset, q + 1, Colors.alpha(module.backgroundColor.getColor(), alpha1).getRGB()); + drawChatAvatar(chatLine, -chatAvatarOffset + 1 + ChatAvatars.getOffsetX(), q - 8 + ChatAvatars.getOffsetY() + Math.round(6 - (alpha / 255f) * 6), alpha); String string = chatLine.getChatComponent().getFormattedText(); GlStateManager.enableBlend(); if (module.betterFont.getValue()) { @@ -176,8 +199,8 @@ public void drawChat(int updateCounter) { GlStateManager.translate(-3.0F, 0.0F, 0.0F); int r = j * m + j; if (r != 0) { - Gui.drawRect(0, 0, 2, 0, module.backgroundColor.getColor().getRGB()); - Gui.drawRect(2, 0, 1, 0, module.backgroundColor.getColor().getRGB()); + Gui.drawRect(-chatAvatarOffset, 0, 2 - chatAvatarOffset, 0, module.backgroundColor.getColor().getRGB()); + Gui.drawRect(2 - chatAvatarOffset, 0, 1 - chatAvatarOffset, 0, module.backgroundColor.getColor().getRGB()); } } @@ -199,19 +222,21 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { ScaledResolution scaledResolution = new ScaledResolution(mc); int i = scaledResolution.getScaleFactor(); float f = this.getChatScale(); - int j = mouseX / i - 2; + // drawChat translates by (2 + avatar gutter) and only then scales, so the gutter lives in + // unscaled screen space and has to come off before the divide, not after. + int j = mouseX / i - 2 - ChatAvatars.getChatOffset(); int k = mouseY / i - 40; j = MathHelper.floor_float((float) j / f); k = MathHelper.floor_float((float) k / f); if (j >= 0 && k >= 0) { - AtomicInteger l = new AtomicInteger(Math.min(this.getLineCount(), this.drawnChatLines.size())); + int lineCount = Math.min(this.getLineCount(), this.drawnChatLines.size()); int fontHeight = mc.fontRendererObj.FONT_HEIGHT; BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); if (BetterChat.using && module.betterFont.getValue()) { fontHeight = FPSMaster.fontManager.s16.getHeight(); } - if (j <= MathHelper.floor_float((float) this.getChatWidth() / this.getChatScale()) && k < fontHeight * l.get() + l.get()) { + if (j <= MathHelper.floor_float((float) this.getChatWidth() / this.getChatScale()) && k < fontHeight * lineCount + lineCount) { int m = k / fontHeight + this.scrollPos; if (m >= 0 && m < this.drawnChatLines.size()) { ChatLine chatLine = this.drawnChatLines.get(m); @@ -245,10 +270,39 @@ public IChatComponent getChatComponent(int mouseX, int mouseY) { public List spilt(IChatComponent chatComponent, int i, FontRenderer chatcomponenttext, boolean l, boolean chatcomponenttext2){ BetterChat module = (BetterChat) FPSMaster.moduleManager.getModule(BetterChat.class); + int chatAvatarOffset = ChatAvatars.getChatOffset(); + int width = Math.max(20, i - chatAvatarOffset); + List lines; if (BetterChat.using && module.betterFont.getValue()) { - return GuiUtilRenderComponents.splitText(chatComponent, i, FPSMaster.fontManager.s16, false, false); + lines = GuiUtilRenderComponents.splitText(chatComponent, width, FPSMaster.fontManager.s16, false, false); } else { - return GuiUtilRenderComponents.splitText(chatComponent, i, mc.fontRendererObj, false, false); + lines = GuiUtilRenderComponents.splitText(chatComponent, width, mc.fontRendererObj, false, false); + } + fpsmaster$rememberChatMessageSource(chatComponent, lines); + return lines; + } + + @Unique + private void fpsmaster$rememberChatMessageSource(IChatComponent source, List lines) { + for (IChatComponent line : lines) { + if (fpsmaster$chatMessageSources.put(line, source) == null) { + fpsmaster$chatMessageOrder.addLast(line); + } + } + // Evict oldest-first instead of wiping the map: a wholesale clear would drop the mapping for + // lines that are still on screen, blanking their avatars while the gutter stays reserved. + while (fpsmaster$chatMessageOrder.size() > FPSMASTER_MAX_CHAT_MESSAGE_SOURCES) { + fpsmaster$chatMessageSources.remove(fpsmaster$chatMessageOrder.removeFirst()); + } + } + + @Unique + private void drawChatAvatar(ChatLine chatLine, int x, int y, int alpha) { + IChatComponent line = chatLine.getChatComponent(); + IChatComponent source = fpsmaster$chatMessageSources.get(line); + ResourceLocation avatar = ChatAvatars.getAvatar(source != null ? source : line); + if (avatar != null) { + ChatAvatarCache.drawHead(avatar, x, y, ChatAvatars.getAvatarSize(), alpha); } } diff --git a/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java b/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java index bbacbae6..ea4f7153 100644 --- a/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java +++ b/src/main/java/top/fpsmaster/forge/mixin/MixinRender.java @@ -38,8 +38,10 @@ public void doRender(Entity entity, double x, double y, double z, float entityYa @Inject(method = "renderLivingLabel", at = @At("HEAD"), cancellable = true) protected void renderLivingLabel(Entity entityIn, String str, double x, double y, double z, int maxDistance, CallbackInfo ci) { - LevelTag.renderName(entityIn, str, x, y, z, maxDistance); - ci.cancel(); + if (LevelTag.using) { + LevelTag.renderName(entityIn, str, x, y, z, maxDistance); + ci.cancel(); + } } @Inject(method = "renderName", at = @At("HEAD"), cancellable = true) diff --git a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java index 347e86c8..78a70f70 100644 --- a/src/main/java/top/fpsmaster/modules/config/ConfigManager.java +++ b/src/main/java/top/fpsmaster/modules/config/ConfigManager.java @@ -29,6 +29,9 @@ import top.fpsmaster.utils.world.ItemsUtil; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.*; public class ConfigManager { @@ -281,8 +284,16 @@ public void loadConfig(String name) throws Exception { if (moduleJson == null || !moduleJson.has("settings")) { continue; } - module.set(moduleJson.get("enabled").getAsBoolean()); - module.key = moduleJson.get("key").getAsInt(); + if (moduleJson.has("enabled") && !moduleJson.get("enabled").isJsonNull()) { + module.set(moduleJson.get("enabled").getAsBoolean()); + } else { + ClientLogger.warn("Missing enabled field in module config: " + module.name); + } + if (moduleJson.has("key") && !moduleJson.get("key").isJsonNull()) { + module.key = moduleJson.get("key").getAsInt(); + } else { + ClientLogger.warn("Missing key field in module config: " + module.name); + } JsonObject settingsJson = moduleJson.getAsJsonObject("settings"); for (Setting setting : module.settings) { try { @@ -317,13 +328,21 @@ public void loadConfig(String name) throws Exception { ((BindSetting) setting).setValue(value.getAsInt()); } else if (setting instanceof MultipleItemSetting && "multiItem".equals(type)) { MultipleItemSetting multipleItemSetting = (MultipleItemSetting) setting; - multipleItemSetting.getValue().clear(); + ArrayList items = new ArrayList<>(); for (JsonElement itemElement : value.getAsJsonArray()) { JsonObject item = itemElement.getAsJsonObject(); int id = item.get("id").getAsInt(); int metadata = item.get("meta").getAsInt(); - multipleItemSetting.addItem(ItemsUtil.getItemStackWithMetadata(Item.getItemById(id), metadata)); + Item resolvedItem = Item.getItemById(id); + if (resolvedItem == null) { + ClientLogger.warn("Skipping unknown item id " + id + " in setting " + module.name + "/" + setting.name); + continue; + } + if (items.size() < MultipleItemSetting.MAX_CAPACITY) { + items.add(ItemsUtil.getItemStackWithMetadata(resolvedItem, metadata)); + } } + multipleItemSetting.setValue(items); } } } catch (Throwable throwable) { @@ -379,8 +398,14 @@ private JsonObject migrateConfigIfNeeded(String name, JsonObject json) throws Fi List migrationPath = resolveMigrationPath(currentVersion, SCHEMA_VERSION); if (migrationPath.isEmpty()) { - ClientLogger.warn("No config migration path from schema " + currentVersion + " to " + SCHEMA_VERSION + ", deleting " + name + ".json"); - deleteConfigFile(name); + File backupFile = backupConfigFile(name, currentVersion); + if (currentVersion > SCHEMA_VERSION) { + ClientLogger.warn("Config schema " + currentVersion + " is newer than supported schema " + SCHEMA_VERSION + + ", resetting " + name + ".json and preserving the original at " + backupFile.getName()); + } else { + ClientLogger.warn("No config migration path from schema " + currentVersion + " to " + SCHEMA_VERSION + + ", resetting " + name + ".json and preserving the original at " + backupFile.getName()); + } return null; } @@ -449,10 +474,15 @@ private void resetConfigToDefaults(String name) throws FileException, Exception loadConfig(name); } - private void deleteConfigFile(String name) throws FileException { + private File backupConfigFile(String name, int schemaVersion) throws FileException { File configFile = ConfigProfileUtils.getProfileFile(name); - ClientLogger.warn("Deleting config file: " + configFile.getAbsolutePath()); - ConfigProfileUtils.deleteConfigFile(name); + File backupFile = new File(configFile.getParentFile(), configFile.getName() + ".bak-schemaV" + schemaVersion); + try { + Files.copy(configFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException exception) { + throw new FileException("Failed to back up config file: " + configFile.getAbsolutePath(), exception); + } + return backupFile; } private void resetAllModulesToDefaults() { diff --git a/src/main/java/top/fpsmaster/ui/custom/Component.java b/src/main/java/top/fpsmaster/ui/custom/Component.java index f3f7b37d..acdd0548 100644 --- a/src/main/java/top/fpsmaster/ui/custom/Component.java +++ b/src/main/java/top/fpsmaster/ui/custom/Component.java @@ -22,6 +22,8 @@ import java.awt.*; public class Component { + private static final Color STENCIL_MASK_COLOR = new Color(255, 255, 255, 255); + private float dragX = 0f; private float dragY = 0f; @@ -114,6 +116,20 @@ public float[] getRealPosition(ScaledResolution sr) { return new float[]{rX, rY}; } + public void drawBlurMask(ScaledResolution sr) { + if (!mod.bg.getValue() || width <= 0f || height <= 0f) { + return; + } + float[] pos = getRealPosition(sr); + float scaledWidth = width * scale; + float scaledHeight = height * scale; + if (mod.rounded.getValue()) { + Rects.roundedImage(Math.round(pos[0] - 2), Math.round(pos[1]), Math.round(scaledWidth), Math.round(scaledHeight), mod.roundRadius.getValue().intValue(), STENCIL_MASK_COLOR); + } else { + Rects.fill(pos[0] - 2, pos[1], scaledWidth, scaledHeight, STENCIL_MASK_COLOR); + } + } + public void display(ScaledResolution sr, int mouseX, int mouseY) { float[] pos = getRealPosition(sr); float rX = pos[0]; diff --git a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java index 354e22c1..1a9f6152 100644 --- a/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java +++ b/src/main/java/top/fpsmaster/ui/custom/ComponentsManager.java @@ -59,6 +59,22 @@ public Component getComponent(Class clazz) { .orElse(null); } + public void drawBackgroundMasks() { + GL11.glPushMatrix(); + net.minecraft.client.gui.ScaledResolution sr = new net.minecraft.client.gui.ScaledResolution(Utility.mc); + GuiScale.fixScale(); + components.forEach(component -> { + if (component.shouldDisplay()) { + try { + component.drawBlurMask(sr); + } catch (Exception e) { + ClientLogger.error("Failed to render component blur mask: " + component.mod.name); + } + } + }); + GL11.glPopMatrix(); + } + // Draw all components on the screen public void draw(int mouseX, int mouseY) { GL11.glPushMatrix(); diff --git a/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java b/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java index 474202a5..16c6a242 100644 --- a/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java +++ b/src/main/java/top/fpsmaster/ui/screens/mainmenu/MainMenu.java @@ -20,6 +20,7 @@ import top.fpsmaster.utils.render.draw.Rects; import top.fpsmaster.utils.render.gui.Backgrounds; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.system.OSUtil; import javax.imageio.ImageIO; import java.awt.*; @@ -153,6 +154,10 @@ public void render(int mouseX, int mouseY, float partialTicks) { } public static void preloadPlayerSkinTexture() { + if (OSUtil.isAndroid()) { + playerSkinLoadFailed = true; + return; + } Minecraft minecraft = Minecraft.getMinecraft(); if (minecraft == null || minecraft.getSession() == null) { return; @@ -171,35 +176,66 @@ public static void preloadPlayerSkinTexture() { loadedSkinPlayerId = normalizedPlayerId; playerSkinTexture = null; playerSkinLoadFailed = false; - FPSMaster.async.runnable(() -> { + try { + FPSMaster.async.runnable(() -> loadPlayerSkinTexture(normalizedPlayerId)); + } catch (RuntimeException exception) { + ClientLogger.warn("Failed to schedule main menu player skin loading"); + fallbackToDefaultSkin(normalizedPlayerId); + SKIN_LOADING.set(false); + } + } + + private static void loadPlayerSkinTexture(String playerId) { + boolean registrationQueued = false; + try { + String skinUrl = readSkinUrl(playerId); + if (skinUrl == null || skinUrl.trim().isEmpty()) { + fallbackToDefaultSkin(playerId); + return; + } + BufferedImage skinImage = readImage(skinUrl); + if (skinImage == null || skinImage.getWidth() < 64 || skinImage.getHeight() < 32) { + fallbackToDefaultSkin(playerId); + return; + } + BufferedImage textureImage = ensureArgbImage(skinImage); + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null) { + fallbackToDefaultSkin(playerId); + return; + } + registrationQueued = true; try { - String skinUrl = readSkinUrl(normalizedPlayerId); - if (skinUrl == null || skinUrl.trim().isEmpty()) { - fallbackToDefaultSkin(normalizedPlayerId); - return; - } - BufferedImage skinImage = readImage(skinUrl); - if (skinImage == null || skinImage.getWidth() < 64 || skinImage.getHeight() < 32) { - fallbackToDefaultSkin(normalizedPlayerId); - return; - } - BufferedImage textureImage = ensureArgbImage(skinImage); - Minecraft.getMinecraft().addScheduledTask(() -> { - if (!normalizedPlayerId.equals(loadedSkinPlayerId)) { - return; - } - playerSkinTexture = Minecraft.getMinecraft() - .getTextureManager() - .getDynamicTextureLocation("fpsmaster_player_skin_" + normalizedPlayerId, new DynamicTexture(textureImage)); - playerSkinLoadFailed = false; - }); - } catch (Exception exception) { - ClientLogger.warn("Failed to load main menu player skin from Mojang API"); - fallbackToDefaultSkin(normalizedPlayerId); - } finally { + minecraft.addScheduledTask(() -> registerPlayerSkinTexture(playerId, textureImage)); + } catch (RuntimeException exception) { + registrationQueued = false; + throw exception; + } + } catch (Exception exception) { + ClientLogger.warn("Failed to load main menu player skin from Mojang API"); + fallbackToDefaultSkin(playerId); + } finally { + if (!registrationQueued) { SKIN_LOADING.set(false); } - }); + } + } + + private static void registerPlayerSkinTexture(String playerId, BufferedImage textureImage) { + try { + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.getTextureManager() == null || !playerId.equals(loadedSkinPlayerId)) { + return; + } + playerSkinTexture = minecraft.getTextureManager() + .getDynamicTextureLocation("fpsmaster_player_skin_" + playerId, new DynamicTexture(textureImage)); + playerSkinLoadFailed = false; + } catch (RuntimeException exception) { + ClientLogger.warn("Failed to register main menu player skin texture"); + fallbackToDefaultSkin(playerId); + } finally { + SKIN_LOADING.set(false); + } } private static void fallbackToDefaultSkin(String playerId) { diff --git a/src/main/java/top/fpsmaster/utils/system/OSUtil.java b/src/main/java/top/fpsmaster/utils/system/OSUtil.java index 923c2032..67606fc8 100644 --- a/src/main/java/top/fpsmaster/utils/system/OSUtil.java +++ b/src/main/java/top/fpsmaster/utils/system/OSUtil.java @@ -1,5 +1,7 @@ package top.fpsmaster.utils.system; +import java.util.Locale; + public class OSUtil { public static boolean supportShader = true; @@ -24,6 +26,15 @@ public static boolean isWindows() { return System.getProperty("os.name").toLowerCase().contains("windows"); } + public static boolean isAndroid() { + return containsIgnoreCase(System.getProperty("os.version"), "android") + || containsIgnoreCase(System.getProperty("java.runtime.name"), "android"); + } + + private static boolean containsIgnoreCase(String value, String expected) { + return value != null && value.toLowerCase(Locale.ROOT).contains(expected); + } + public static boolean supportShader() { return supportShader; } diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 28aba156..12041472 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -213,6 +213,13 @@ betterchat.background=Show Background betterchat.foldmessage=Fold Message betterchat.copymessage=Copy Message +chatavatars=Chat Avatars +chatavatars.desc=Shows player heads next to chat messages +chatavatars.size=Avatar Size +chatavatars.gap=Avatar Gap +chatavatars.offsetx=Horizontal Offset +chatavatars.offsety=Vertical Offset + combodisplay=Combo Counter combodisplay.desc=Displays current combo count combodisplay.textcolor=Text Color diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index d55f69bc..2ca4dc23 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -213,6 +213,13 @@ betterchat.background=背景 betterchat.foldmessage=折叠消息 betterchat.copymessage=复制消息 +chatavatars=聊天头像 +chatavatars.desc=在聊天消息旁显示玩家头像 +chatavatars.size=头像大小 +chatavatars.gap=头像间距 +chatavatars.offsetx=水平偏移 +chatavatars.offsety=垂直偏移 + combodisplay=连击显示 combodisplay.desc=显示连击数 combodisplay.textcolor=文字颜色