village glow , container glow, activity messages.
This commit is contained in:
seb
2026-04-28 04:56:33 +02:00
parent 2504f5405d
commit 28aa0cb86b
8 changed files with 328 additions and 44 deletions

View File

@@ -32,6 +32,8 @@ repositories {
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.fabric_loader_version}"
// Official 26.1 template uses `implementation` for fabric-api (not modImplementation).
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
implementation "maven.modrinth:malilib:${project.malilib_version}"
compileOnly "com.terraformersmc:modmenu:${project.mod_menu_version}"
}

View File

@@ -9,7 +9,7 @@ mod_name = AutoTrade
author = sebseb7
mod_file_name = autotrade-fabric
mod_version = 0.0.13
mod_version = 0.0.14
malilib_version = 0.28.2
minecraft_version_min = 26.1.2
@@ -18,4 +18,5 @@ minecraft_version_out = 26.1.2
minecraft_version = 26.1.2
fabric_loader_version = 0.19.2
fabric_api_version = 0.145.4+26.1.2
mod_menu_version = 18.0.0-alpha.8

View File

@@ -3,6 +3,7 @@ package com.github.sebseb7.autotrade;
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 fi.dy.masa.malilib.config.ConfigManager;
import fi.dy.masa.malilib.config.options.ConfigString;
import fi.dy.masa.malilib.event.InputEventHandler;
@@ -15,6 +16,8 @@ public class InitHandler implements IInitializationHandler {
public void registerModHandlers() {
ConfigManager.getInstance().registerConfigHandler(Reference.MOD_ID, new Configs());
TraderHighlightRenderer.register();
InputHandler handler = new InputHandler();
InputEventHandler.getKeybindManager().registerKeybindProvider(handler);

View File

@@ -21,6 +21,7 @@ import net.minecraft.world.entity.decoration.ItemFrame;
import net.minecraft.world.entity.npc.villager.Villager;
import net.minecraft.world.entity.npc.wanderingtrader.WanderingTrader;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.MerchantMenu;
import net.minecraft.world.inventory.ShulkerBoxMenu;
@@ -47,7 +48,32 @@ final class AutoTradeClientTick {
private int voidDelay = 0;
private int containerDelay = 0;
/**
* 1 second at 20 TPS — client wireframe highlight (see
* {@code TraderHighlightRenderer}).
*/
private static final int TRADER_HIGHLIGHT_TICKS = 20;
private int traderGlowTicksRemaining = 0;
private int traderGlowEntityId = -1;
private int inputContainerHighlightTicks = 0;
private int outputContainerHighlightTicks = 0;
/**
* Entity to draw in-world highlight for; {@code null} when inactive or unknown
* id.
*/
Entity getTraderGlowEntityForRender(Minecraft mc) {
if (traderGlowTicksRemaining <= 0 || traderGlowEntityId < 0 || mc.level == null) {
return null;
}
return findEntityById(mc, traderGlowEntityId);
}
void tick(Minecraft mc) {
tickTraderGlow(mc);
tickContainerHighlights(mc);
if (voidDelay > 0) {
if (Configs.Generic.VOID_TRADING_DELAY_AFTER_TELEPORT.getBooleanValue()) {
boolean found = false;
@@ -152,6 +178,7 @@ final class AutoTradeClientTick {
new BlockHitResult(ic, Direction.UP, input, false));
containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue();
inputOpened = true;
inputContainerHighlightTicks = TRADER_HIGHLIGHT_TICKS;
return;
}
if ((mc.player.distanceToSqr(oc) < 16.0D) && (outputInRange == false)) {
@@ -160,6 +187,7 @@ final class AutoTradeClientTick {
new BlockHitResult(oc, Direction.UP, output, false));
containerDelay = Configs.Generic.CONTAINER_CLOSE_DELAY.getIntegerValue();
outputOpened = true;
outputContainerHighlightTicks = TRADER_HIGHLIGHT_TICKS;
return;
}
if (mc.player.distanceToSqr(ic) > 25.0D) {
@@ -173,7 +201,6 @@ final class AutoTradeClientTick {
tickCount++;
if (tickCount > 200) {
tickCount = 0;
villagersInRange.clear();
inputInRange = false;
outputInRange = false;
var cur = GuiUtils.getCurrentScreen();
@@ -264,61 +291,177 @@ final class AutoTradeClientTick {
MerchantOffers offers = menu.getOffers();
for (int i = 0; i < offers.size(); i++) {
MerchantOffer offer = offers.get(i);
int tradesLeft = offer.getMaxUses() - offer.getUses();
if (TradeItemSpec.matches(offer.getResult(), buyItemStr) && Configs.Generic.ENABLE_BUY.getBooleanValue()
&& offer.getResult().getCount() <= Configs.Generic.BUY_LIMIT.getIntegerValue()) {
Slot slot = menu.getSlot(2);
menu.setSelectionHint(i);
mc.player.connection.send(new ServerboundSelectTradePacket(i));
AutoTrade.bought += offer.getMaxUses();
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.trade_bought",
formatItemCountAndName(offer.getResult()), formatOfferPrice(offer));
try {
ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index);
} catch (Exception e) {
System.out.println("err " + e);
if (tradesLeft > 0 && playerHasMerchantCosts(mc.player, offer)) {
Slot slot = menu.getSlot(2);
menu.setSelectionHint(i);
mc.player.connection.send(new ServerboundSelectTradePacket(i));
AutoTrade.bought += offer.getMaxUses();
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.trade_bought",
formatItemCountNameForTrades(offer.getResult(), tradesLeft),
formatOfferPriceForTrades(offer, tradesLeft));
try {
ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index);
} catch (Exception e) {
System.out.println("err " + e);
}
}
}
if (TradeItemSpec.matches(offer.getCostA(), sellItemStr)
&& Configs.Generic.ENABLE_SELL.getBooleanValue()
&& offer.getCostA().getCount() <= Configs.Generic.SELL_LIMIT.getIntegerValue()) {
Slot slot = menu.getSlot(2);
menu.setSelectionHint(i);
AutoTrade.sold += offer.getMaxUses();
mc.player.connection.send(new ServerboundSelectTradePacket(i));
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.trade_sold",
formatItemCountAndName(offer.getCostA()) + formatOptionalSecondCost(offer),
formatItemCountAndName(offer.getResult()));
try {
ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index);
} catch (Exception e) {
System.out.println("err " + e);
if (tradesLeft > 0 && playerHasMerchantCosts(mc.player, offer)) {
Slot slot = menu.getSlot(2);
menu.setSelectionHint(i);
AutoTrade.sold += offer.getMaxUses();
mc.player.connection.send(new ServerboundSelectTradePacket(i));
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.trade_sold",
formatItemCountNameForTrades(offer.getCostA(), tradesLeft)
+ formatOptionalSecondCostForTrades(offer, tradesLeft),
formatItemCountNameForTrades(offer.getResult(), tradesLeft));
try {
ContainerIoHelper.quickMoveResultSlot(mc, menu, slot.index);
} catch (Exception e) {
System.out.println("err " + e);
}
}
}
}
}
screen.onClose();
startTraderGlow(mc, villagerActive);
}
/** e.g. "3× Book" */
private void tickTraderGlow(Minecraft mc) {
if (mc.level == null || traderGlowTicksRemaining <= 0) {
return;
}
traderGlowTicksRemaining--;
if (traderGlowTicksRemaining == 0) {
traderGlowEntityId = -1;
}
}
private void tickContainerHighlights(Minecraft mc) {
if (mc.level == null) {
return;
}
if (inputContainerHighlightTicks > 0) {
inputContainerHighlightTicks--;
}
if (outputContainerHighlightTicks > 0) {
outputContainerHighlightTicks--;
}
}
int getInputContainerHighlightTicks() {
return inputContainerHighlightTicks;
}
int getOutputContainerHighlightTicks() {
return outputContainerHighlightTicks;
}
private void startTraderGlow(Minecraft mc, int entityId) {
if (mc.level == null) {
return;
}
if (findEntityById(mc, entityId) == null) {
traderGlowTicksRemaining = 0;
traderGlowEntityId = -1;
return;
}
traderGlowEntityId = entityId;
traderGlowTicksRemaining = TRADER_HIGHLIGHT_TICKS;
}
private static Entity findEntityById(Minecraft mc, int entityId) {
for (Entity e : mc.level.entitiesForRendering()) {
if (e.getId() == entityId) {
return e;
}
}
return null;
}
/**
* Same stack rules as the merchant menu: player must have enough of each
* non-empty cost before we fire packets or show a trade toast.
*/
private static boolean playerHasMerchantCosts(Player player, MerchantOffer offer) {
if (!costRequirementMet(player.getInventory(), offer.getCostA())) {
return false;
}
return costRequirementMet(player.getInventory(), offer.getCostB());
}
private static boolean costRequirementMet(Inventory inv, ItemStack required) {
if (required.isEmpty()) {
return true;
}
int need = required.getCount();
int have = 0;
for (int s = 0; s < inv.getContainerSize(); s++) {
ItemStack st = inv.getItem(s);
if (ItemStack.isSameItemSameComponents(st, required)) {
have += st.getCount();
if (have >= need) {
return true;
}
}
}
return false;
}
/** e.g. "3× Book" (one villager use). */
private static String formatItemCountAndName(ItemStack stack) {
return stack.getCount() + "× " + stack.getHoverName().getString();
}
/** For buying: the stacks you pay (first + optional second slot). */
private static String formatOfferPrice(MerchantOffer offer) {
String a = offer.getCostA().isEmpty() ? null : formatItemCountAndName(offer.getCostA());
/**
* Per-trade count × how many of this offer remain before the trade, e.g. 1
* iron/trade × 12 runs → "12× …".
*/
private static String formatItemCountNameForTrades(ItemStack perTrade, int remainingOfferUses) {
if (remainingOfferUses <= 0) {
return formatItemCountAndName(perTrade);
}
return (perTrade.getCount() * remainingOfferUses) + "× " + perTrade.getHoverName().getString();
}
/** For buying: the stacks you pay, scaled to how many of this offer remain. */
private static String formatOfferPriceForTrades(MerchantOffer offer, int t) {
if (t <= 0) {
String a = offer.getCostA().isEmpty() ? null : formatItemCountAndName(offer.getCostA());
if (offer.getCostB().isEmpty()) {
return a != null ? a : "";
}
String b = formatItemCountAndName(offer.getCostB());
return a == null ? b : a + " + " + b;
}
String a = offer.getCostA().isEmpty()
? null
: (offer.getCostA().getCount() * t) + "× " + offer.getCostA().getHoverName().getString();
if (offer.getCostB().isEmpty()) {
return a != null ? a : "";
}
String b = formatItemCountAndName(offer.getCostB());
String b = (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString();
return a == null ? b : a + " + " + b;
}
/** If the trade has a second input item, show it with " + " */
private static String formatOptionalSecondCost(MerchantOffer offer) {
/**
* If the trade has a second cost item, " + 2× …" scaled to remaining offer
* uses.
*/
private static String formatOptionalSecondCostForTrades(MerchantOffer offer, int t) {
if (offer.getCostB().isEmpty()) {
return "";
}
return " + " + formatItemCountAndName(offer.getCostB());
if (t <= 0) {
return " + " + formatItemCountAndName(offer.getCostB());
}
return " + " + (offer.getCostB().getCount() * t) + "× " + offer.getCostB().getHoverName().getString();
}
}

View File

@@ -4,11 +4,14 @@ import com.github.sebseb7.autotrade.config.Configs;
import com.github.sebseb7.autotrade.util.TradeItemSpec;
import fi.dy.masa.malilib.gui.Message;
import fi.dy.masa.malilib.util.InfoUtils;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
final class ContainerIoHelper {
@@ -27,22 +30,23 @@ final class ContainerIoHelper {
int maxKeep = Configs.Generic.MAX_INPUT_ITEMS.getIntegerValue() * 64;
if (Configs.Generic.ENABLE_BUY.getBooleanValue()) {
String buySpec = Configs.Generic.BUY_ITEM.getStringValue();
MoveTotals movedBought = new MoveTotals();
for (int i = 0; i < menu.slots.size(); i++) {
Slot s = menu.getSlot(i);
if (s.container == playerInv && TradeItemSpec.matches(s.getItem(), buySpec)) {
ItemStack stack = s.getItem();
if (stack.isEmpty()) {
if (s.getItem().isEmpty()) {
continue;
}
ItemStack beforeMove = s.getItem().copy();
try {
quickMoveResultSlot(mc, menu, i);
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO,
"autotrade.message.moved_bought_to_output", formatStackForMessage(stack));
movedBought.add(beforeMove);
} catch (Exception e) {
System.out.println("err " + e);
}
}
}
movedBought.flush("autotrade.message.moved_bought_to_output");
}
quickMovePlayerExcessOverCap(mc, menu, playerInv, EMERALD_SPEC, maxKeep);
if (Configs.Generic.ENABLE_SELL.getBooleanValue()) {
@@ -63,6 +67,7 @@ final class ContainerIoHelper {
}
}
Minecraft mc = Minecraft.getInstance();
MoveTotals movedFromInput = new MoveTotals();
for (int i = 0; i < menu.slots.size(); i++) {
Slot s = menu.getSlot(i);
if (s.container == playerInv) {
@@ -71,20 +76,20 @@ final class ContainerIoHelper {
if (TradeItemSpec.matches(s.getItem(), itemToTake)) {
if (inputCount < (Configs.Generic.MAX_INPUT_ITEMS.getIntegerValue() * 64)) {
inputCount += s.getItem().getCount();
ItemStack stack = s.getItem();
if (stack.isEmpty()) {
if (s.getItem().isEmpty()) {
continue;
}
ItemStack beforeMove = s.getItem().copy();
try {
quickMoveResultSlot(mc, menu, i);
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, "autotrade.message.moved_from_input",
formatStackForMessage(stack));
movedFromInput.add(beforeMove);
} catch (Exception e) {
System.out.println("err " + e);
}
}
}
}
movedFromInput.flush("autotrade.message.moved_from_input");
}
private static int countMatchingOnPlayer(AbstractContainerMenu menu, Inventory playerInv, String spec) {
@@ -104,6 +109,7 @@ final class ContainerIoHelper {
*/
private static void quickMovePlayerExcessOverCap(Minecraft mc, AbstractContainerMenu menu, Inventory playerInv,
String spec, int maxKeep) {
MoveTotals movedExcess = new MoveTotals();
while (true) {
int before = countMatchingOnPlayer(menu, playerInv, spec);
if (before <= maxKeep) {
@@ -115,14 +121,13 @@ final class ContainerIoHelper {
if (s.container != playerInv || !TradeItemSpec.matches(s.getItem(), spec)) {
continue;
}
ItemStack stack = s.getItem();
if (stack.isEmpty()) {
if (s.getItem().isEmpty()) {
continue;
}
ItemStack beforeMove = s.getItem().copy();
try {
quickMoveResultSlot(mc, menu, i);
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO,
"autotrade.message.moved_excess_to_output", formatStackForMessage(stack));
movedExcess.add(beforeMove);
} catch (Exception e) {
System.out.println("err " + e);
}
@@ -137,9 +142,33 @@ final class ContainerIoHelper {
break;
}
}
movedExcess.flush("autotrade.message.moved_excess_to_output");
}
private static String formatStackForMessage(ItemStack stack) {
return stack.getCount() + "× " + stack.getHoverName().getString();
}
/** Merges moved amounts by {@link Item} for one batched toast line per type. */
private static final class MoveTotals {
private final Map<Item, Integer> counts = new HashMap<>();
void add(ItemStack stack) {
if (stack.isEmpty()) {
return;
}
counts.merge(stack.getItem(), stack.getCount(), Integer::sum);
}
void flush(String translationKey) {
if (counts.isEmpty()) {
return;
}
for (Map.Entry<Item, Integer> e : counts.entrySet()) {
ItemStack line = new ItemStack(e.getKey(), e.getValue());
InfoUtils.showGuiOrInGameMessage(Message.MessageType.INFO, translationKey, formatStackForMessage(line));
}
counts.clear();
}
}
}

View File

@@ -8,6 +8,7 @@ import fi.dy.masa.malilib.hotkeys.IKeybind;
import fi.dy.masa.malilib.hotkeys.KeyAction;
import fi.dy.masa.malilib.interfaces.IClientTickHandler;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity;
public class KeybindCallbacks implements IHotkeyCallback, IClientTickHandler {
private static final KeybindCallbacks INSTANCE = new KeybindCallbacks();
@@ -31,6 +32,18 @@ public class KeybindCallbacks implements IHotkeyCallback, IClientTickHandler {
return Configs.Generic.ENABLED.getBooleanValue();
}
public Entity getTraderHighlightEntity(Minecraft mc) {
return clientTick.getTraderGlowEntityForRender(mc);
}
public int getInputContainerHighlightTicks() {
return clientTick.getInputContainerHighlightTicks();
}
public int getOutputContainerHighlightTicks() {
return clientTick.getOutputContainerHighlightTicks();
}
@Override
public boolean onKeyAction(KeyAction action, IKeybind key) {
return HotkeyActions.handle(Minecraft.getInstance(), key);

View File

@@ -0,0 +1,92 @@
package com.github.sebseb7.autotrade.render;
import com.github.sebseb7.autotrade.config.Configs;
import com.github.sebseb7.autotrade.event.KeybindCallbacks;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
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.renderer.MultiBufferSource;
import net.minecraft.client.renderer.ShapeRenderer;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.shapes.Shapes;
/**
* Client wireframe highlights: last-traded villager, and input/output container
* blocks for one second after the mod opens them (same idea as Meteor-style ESP
* boxes).
*/
public final class TraderHighlightRenderer {
private static final ShapeRenderer SHAPE_RENDERER = new ShapeRenderer();
private static final int TRADER_OUTLINE_COLOR = 0xFF66FF66;
private static final int INPUT_CONTAINER_COLOR = 0xFFFF6666;
private static final int OUTPUT_CONTAINER_COLOR = 0xFF6666FF;
private static final float LINE_WIDTH = 2.5F;
private TraderHighlightRenderer() {
}
public static void register() {
LevelRenderEvents.AFTER_SOLID_FEATURES.register(TraderHighlightRenderer::render);
}
private static void render(LevelRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) {
return;
}
KeybindCallbacks kb = KeybindCallbacks.getInstance();
Entity trader = kb.getTraderHighlightEntity(mc);
int inTicks = kb.getInputContainerHighlightTicks();
int outTicks = kb.getOutputContainerHighlightTicks();
if (trader == null && inTicks <= 0 && outTicks <= 0) {
return;
}
MultiBufferSource.BufferSource bufferSource = context.bufferSource();
VertexConsumer consumer = bufferSource.getBuffer(RenderTypes.lines());
PoseStack drawPose = new PoseStack();
Vec3 camera = mc.gameRenderer.getMainCamera().position();
float tickDelta = mc.getDeltaTracker().getGameTimeDeltaPartialTick(true);
if (trader != null) {
double offX = Mth.lerp(tickDelta, trader.xOld, trader.getX()) - trader.getX();
double offY = Mth.lerp(tickDelta, trader.yOld, trader.getY()) - trader.getY();
double offZ = Mth.lerp(tickDelta, trader.zOld, trader.getZ()) - trader.getZ();
AABB worldBox = trader.getBoundingBox().move(offX, offY, offZ);
AABB cameraRelative = worldBox.move(-camera.x, -camera.y, -camera.z);
SHAPE_RENDERER.renderShape(drawPose, consumer, Shapes.create(cameraRelative), 0.0D, 0.0D, 0.0D,
TRADER_OUTLINE_COLOR, LINE_WIDTH);
}
if (inTicks > 0) {
BlockPos in = new BlockPos(Configs.Generic.INPUT_CONTAINER_X.getIntegerValue(),
Configs.Generic.INPUT_CONTAINER_Y.getIntegerValue(),
Configs.Generic.INPUT_CONTAINER_Z.getIntegerValue());
drawBlockBox(drawPose, consumer, camera, in, INPUT_CONTAINER_COLOR);
}
if (outTicks > 0) {
BlockPos out = new BlockPos(Configs.Generic.OUTPUT_CONTAINER_X.getIntegerValue(),
Configs.Generic.OUTPUT_CONTAINER_Y.getIntegerValue(),
Configs.Generic.OUTPUT_CONTAINER_Z.getIntegerValue());
drawBlockBox(drawPose, consumer, camera, out, OUTPUT_CONTAINER_COLOR);
}
}
private static void drawBlockBox(PoseStack drawPose, VertexConsumer consumer, Vec3 camera, BlockPos pos,
int color) {
AABB world = AABB.encapsulatingFullBlocks(pos, pos);
AABB cameraRelative = world.move(-camera.x, -camera.y, -camera.z);
SHAPE_RENDERER.renderShape(drawPose, consumer, Shapes.create(cameraRelative), 0.0D, 0.0D, 0.0D, color,
LINE_WIDTH);
}
}

View File

@@ -31,6 +31,7 @@
"depends": {
"minecraft": ">=${minecraft_version_min}",
"malilib": ">=${malilib_version}"
"malilib": ">=${malilib_version}",
"fabric-api": ">=0.145.0"
}
}