diff --git a/src/main/java/com/github/sebseb7/autotrade/InitHandler.java b/src/main/java/com/github/sebseb7/autotrade/InitHandler.java index 223b69e..df82e32 100644 --- a/src/main/java/com/github/sebseb7/autotrade/InitHandler.java +++ b/src/main/java/com/github/sebseb7/autotrade/InitHandler.java @@ -4,6 +4,7 @@ import com.github.sebseb7.autotrade.config.Configs; import com.github.sebseb7.autotrade.event.InputHandler; import com.github.sebseb7.autotrade.event.KeybindCallbacks; import com.github.sebseb7.autotrade.render.TraderHighlightRenderer; +import com.github.sebseb7.autotrade.render.VillagerTradeOverlayRenderer; import fi.dy.masa.malilib.config.ConfigManager; import fi.dy.masa.malilib.config.options.ConfigString; import fi.dy.masa.malilib.event.InputEventHandler; @@ -17,6 +18,7 @@ public class InitHandler implements IInitializationHandler { ConfigManager.getInstance().registerConfigHandler(Reference.MOD_ID, new Configs()); TraderHighlightRenderer.register(); + VillagerTradeOverlayRenderer.register(); InputHandler handler = new InputHandler(); InputEventHandler.getKeybindManager().registerKeybindProvider(handler); diff --git a/src/main/java/com/github/sebseb7/autotrade/config/Configs.java b/src/main/java/com/github/sebseb7/autotrade/config/Configs.java index 602386c..8faf5c5 100644 --- a/src/main/java/com/github/sebseb7/autotrade/config/Configs.java +++ b/src/main/java/com/github/sebseb7/autotrade/config/Configs.java @@ -58,11 +58,14 @@ public class Configs implements IConfigHandler { "true: Start the delay after the villager was unloaded; false: Start the delay after the trade has been initiated"); public static final ConfigInteger CONTAINER_CLOSE_DELAY = new ConfigInteger("containerCloseDelay", 0, 0, 30000000, "delay in ticks; to get signal from trapped chest"); + public static final ConfigBoolean SHOW_TRADES = new ConfigBoolean("showTrades", true, + "Display villager/wandering-trader trades above their heads (requires trading with them once to cache the offers)"); public static final ImmutableList OPTIONS = ImmutableList.of(ENABLED, ITEM_FRAME, GLASS_BLOCK, SELECTOR_OFFSET, ENABLE_SELL, SELL_ITEM, SELL_LIMIT, ENABLE_BUY, BUY_ITEM, BUY_LIMIT, MAX_INPUT_ITEMS, INPUT_CONTAINER_X, INPUT_CONTAINER_Y, INPUT_CONTAINER_Z, OUTPUT_CONTAINER_X, OUTPUT_CONTAINER_Y, - OUTPUT_CONTAINER_Z, VOID_TRADING_DELAY, VOID_TRADING_DELAY_AFTER_TELEPORT, CONTAINER_CLOSE_DELAY); + OUTPUT_CONTAINER_Z, VOID_TRADING_DELAY, VOID_TRADING_DELAY_AFTER_TELEPORT, CONTAINER_CLOSE_DELAY, + SHOW_TRADES); } public static void loadFromFile() { diff --git a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java index c54e310..df85bef 100644 --- a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java @@ -2,6 +2,7 @@ package com.github.sebseb7.autotrade.event; import com.github.sebseb7.autotrade.AutoTrade; import com.github.sebseb7.autotrade.config.Configs; +import com.github.sebseb7.autotrade.render.VillagerTradeCache; import com.github.sebseb7.autotrade.util.TradeItemSpec; import fi.dy.masa.malilib.gui.Message; import fi.dy.masa.malilib.util.GuiUtils; @@ -289,6 +290,12 @@ final class AutoTradeClientTick { state = true; MerchantMenu menu = screen.getMenu(); MerchantOffers offers = menu.getOffers(); + + // Cache offers for the in-world trade overlay. + Entity activeEntity = findEntityById(mc, villagerActive); + if (activeEntity != null && offers != null && !offers.isEmpty()) { + VillagerTradeCache.put(activeEntity.getUUID(), offers); + } for (int i = 0; i < offers.size(); i++) { MerchantOffer offer = offers.get(i); int tradesLeft = offer.getMaxUses() - offer.getUses(); diff --git a/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeCache.java b/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeCache.java new file mode 100644 index 0000000..d0a88b8 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeCache.java @@ -0,0 +1,41 @@ +package com.github.sebseb7.autotrade.render; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minecraft.world.item.trading.MerchantOffers; + +/** + * Client-side cache of villager/wandering-trader trade offers, keyed by entity + * UUID. + * + *

Populated by {@code AutoTradeClientTick} when the mod opens a merchant + * screen; consumed by {@link VillagerTradeOverlayRenderer} to draw trade labels + * above each villager's head. + */ +public final class VillagerTradeCache { + private static final Map CACHE = new ConcurrentHashMap<>(); + + private VillagerTradeCache() { + } + + /** Store (or update) the offers we observed for a given entity. */ + public static void put(UUID entityUuid, MerchantOffers offers) { + CACHE.put(entityUuid, offers); + } + + /** Retrieve cached offers, or {@code null} if we haven't seen this entity trade yet. */ + public static MerchantOffers get(UUID entityUuid) { + return CACHE.get(entityUuid); + } + + /** Remove a single entry (e.g. when the entity leaves render distance). */ + public static void remove(UUID entityUuid) { + CACHE.remove(entityUuid); + } + + /** Drop every cached entry (e.g. on world change). */ + public static void clear() { + CACHE.clear(); + } +} diff --git a/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeOverlayRenderer.java b/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeOverlayRenderer.java new file mode 100644 index 0000000..e871806 --- /dev/null +++ b/src/main/java/com/github/sebseb7/autotrade/render/VillagerTradeOverlayRenderer.java @@ -0,0 +1,165 @@ +package com.github.sebseb7.autotrade.render; + +import com.github.sebseb7.autotrade.config.Configs; +import com.mojang.blaze3d.vertex.PoseStack; +import java.util.ArrayList; +import java.util.List; +import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderContext; +import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.npc.villager.Villager; +import net.minecraft.world.entity.npc.wanderingtrader.WanderingTrader; +import net.minecraft.world.item.trading.MerchantOffer; +import net.minecraft.world.item.trading.MerchantOffers; +import net.minecraft.world.phys.Vec3; +import org.joml.Matrix4f; + +/** + * Renders a compact summary of each villager's known trades above their head. + * + *

Trade data comes from {@link VillagerTradeCache}, which is populated when + * the mod opens a merchant screen. Villagers whose trades haven't been seen + * yet show nothing. + */ +public final class VillagerTradeOverlayRenderer { + + /** Vertical gap between successive trade lines (in world-space blocks). */ + private static final float LINE_SPACING = 0.25F; + + /** World-space scale of the text (vanilla name-tags use ~0.025). */ + private static final float TEXT_SCALE = 0.02F; + + /** Text background colour (semi-transparent dark). */ + private static final int BG_COLOR = 0x80000000; + + /** Normal trade text colour (white). */ + private static final int TEXT_COLOR = 0xFFFFFFFF; + + /** Depleted trade text colour (grey/red). */ + private static final int DEPLETED_COLOR = 0xFFFF6666; + + private VillagerTradeOverlayRenderer() { + } + + public static void register() { + LevelRenderEvents.AFTER_SOLID_FEATURES.register(VillagerTradeOverlayRenderer::render); + } + + private static void render(LevelRenderContext context) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || mc.player == null) { + return; + } + if (!Configs.Generic.SHOW_TRADES.getBooleanValue()) { + return; + } + + Font font = mc.font; + MultiBufferSource.BufferSource bufferSource = context.bufferSource(); + Vec3 camera = mc.gameRenderer.getMainCamera().position(); + float tickDelta = mc.getDeltaTracker().getGameTimeDeltaPartialTick(true); + + for (Entity entity : mc.level.entitiesForRendering()) { + if (!(entity instanceof Villager) && !(entity instanceof WanderingTrader)) { + continue; + } + // Only render for villagers within a reasonable distance. + if (entity.distanceToSqr(mc.player) > 64.0 * 64.0) { + continue; + } + + MerchantOffers offers = VillagerTradeCache.get(entity.getUUID()); + if (offers == null || offers.isEmpty()) { + continue; + } + + // Build compact trade lines: "CostA [+ CostB] → Result (uses/max)" + List lines = buildTradeLines(offers); + if (lines.isEmpty()) { + continue; + } + + // Interpolated entity position relative to camera. + double x = Mth.lerp(tickDelta, entity.xOld, entity.getX()) - camera.x; + double y = Mth.lerp(tickDelta, entity.yOld, entity.getY()) - camera.y; + double z = Mth.lerp(tickDelta, entity.zOld, entity.getZ()) - camera.z; + + // Place the first line above the entity's head (entity height + small gap). + float baseY = entity.getBbHeight() + 0.6F; + + PoseStack poseStack = new PoseStack(); + poseStack.pushPose(); + poseStack.translate(x, y + baseY, z); + + // Face the camera (billboard). + poseStack.mulPose(mc.gameRenderer.getMainCamera().rotation()); + poseStack.scale(-TEXT_SCALE, -TEXT_SCALE, TEXT_SCALE); + + // Draw lines from top (highest index) to bottom (index 0). + for (int i = 0; i < lines.size(); i++) { + TradeLineEntry entry = lines.get(i); + float lineOffsetY = -(lines.size() - 1 - i) * (font.lineHeight + 2); + + Matrix4f matrix = poseStack.last().pose(); + matrix = new Matrix4f(matrix); + matrix.translate(0, lineOffsetY, 0); + + int textWidth = font.width(entry.text); + float textX = -textWidth / 2.0F; + + // Background + font.drawInBatch(entry.text, textX, 0, entry.color, false, matrix, bufferSource, + Font.DisplayMode.SEE_THROUGH, BG_COLOR, 0xF000F0); + // Foreground + font.drawInBatch(entry.text, textX, 0, entry.color, false, matrix, bufferSource, + Font.DisplayMode.NORMAL, 0, 0xF000F0); + } + + poseStack.popPose(); + } + } + + private static List buildTradeLines(MerchantOffers offers) { + List lines = new ArrayList<>(); + for (int i = 0; i < offers.size(); i++) { + MerchantOffer offer = offers.get(i); + StringBuilder sb = new StringBuilder(); + + // Cost A + if (!offer.getCostA().isEmpty()) { + sb.append(offer.getCostA().getCount()).append("× ") + .append(offer.getCostA().getHoverName().getString()); + } + + // Cost B (optional) + if (!offer.getCostB().isEmpty()) { + if (sb.length() > 0) { + sb.append(" + "); + } + sb.append(offer.getCostB().getCount()).append("× ") + .append(offer.getCostB().getHoverName().getString()); + } + + sb.append(" → "); + + // Result + sb.append(offer.getResult().getCount()).append("× ") + .append(offer.getResult().getHoverName().getString()); + + // Remaining uses + int remaining = offer.getMaxUses() - offer.getUses(); + sb.append(" (").append(remaining).append("/").append(offer.getMaxUses()).append(")"); + + boolean depleted = remaining <= 0; + lines.add(new TradeLineEntry(sb.toString(), depleted ? DEPLETED_COLOR : TEXT_COLOR)); + } + return lines; + } + + private record TradeLineEntry(String text, int color) { + } +}