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 e8db627..7e1a194 100644 --- a/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java +++ b/src/main/java/com/github/sebseb7/autotrade/event/AutoTradeClientTick.java @@ -37,10 +37,12 @@ import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.trading.MerchantOffer; import net.minecraft.world.item.trading.MerchantOffers; +import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.HitResult; import net.minecraft.world.phys.Vec3; final class AutoTradeClientTick { @@ -68,6 +70,16 @@ final class AutoTradeClientTick { private int outputContainerHighlightTicks = 0; private int postMerchantInventorySyncTicks = 0; + /** + * Entity we have already snapped the camera onto and are waiting for line of + * sight to clear on. Prevents re-rotating to the same villager every tick + * while a block briefly obstructs the view; reset once we interact (or after + * a timeout) so the next villager can be acquired. + */ + private int rotatingTargetId = -1; + private int rotatingTargetTicks = 0; + private static final int LOS_TIMEOUT_TICKS = 60; + /** * Entity to draw in-world highlight for; {@code null} when inactive or unknown * id. @@ -157,20 +169,38 @@ final class AutoTradeClientTick { if (!found) { if (!newVillagersInRange.contains(entity)) { found = true; - newVillagersInRange.add(entity); // Paper/Folia rejects entity-interact packets unless the player is - // actually facing the entity, so align the look vector first and - // mirror vanilla's full INTERACT_AT + INTERACT + swing sequence. - Vec3 eyePos = mc.player.getEyePosition(); - Vec3 targetEye = entity.getEyePosition(); - Vec3 delta = targetEye.subtract(eyePos); - double horiz = Math.sqrt(delta.x * delta.x + delta.z * delta.z); - float yaw = (float) (Math.toDegrees(Math.atan2(delta.z, delta.x)) - 90.0); - float pitch = (float) -Math.toDegrees(Math.atan2(delta.y, horiz)); - mc.player.setYRot(yaw); - mc.player.setXRot(pitch); - mc.player.yHeadRot = yaw; - EntityHitResult ehr = new EntityHitResult(entity, targetEye); + // actually facing a visible part of the entity hitbox. Aim at any + // point we have a clean line of sight to (eyes, head, chest, feet); + // if none of them are visible yet, wait without flooding the camera + // with re-rotations every tick. + Vec3 aimPoint = firstVisiblePoint(mc, entity); + if (rotatingTargetId != entity.getId()) { + rotatingTargetId = entity.getId(); + rotatingTargetTicks = 0; + Vec3 lookAt = aimPoint != null ? aimPoint : entity.getEyePosition(); + Vec3 eyePos = mc.player.getEyePosition(); + Vec3 delta = lookAt.subtract(eyePos); + double horiz = Math.sqrt(delta.x * delta.x + delta.z * delta.z); + float yaw = (float) (Math.toDegrees(Math.atan2(delta.z, delta.x)) - 90.0); + float pitch = (float) -Math.toDegrees(Math.atan2(delta.y, horiz)); + mc.player.setYRot(yaw); + mc.player.setXRot(pitch); + mc.player.yHeadRot = yaw; + } else { + rotatingTargetTicks++; + } + if (aimPoint == null) { + if (rotatingTargetTicks > LOS_TIMEOUT_TICKS) { + // Give up so the player can move past this villager. + newVillagersInRange.add(entity); + rotatingTargetId = -1; + } + break; + } + newVillagersInRange.add(entity); + rotatingTargetId = -1; + EntityHitResult ehr = new EntityHitResult(entity, aimPoint); AutoTrade.autoInteracting = true; try { //? if mc26 { @@ -459,6 +489,38 @@ final class AutoTradeClientTick { traderGlowTicksRemaining = TRADER_HIGHLIGHT_TICKS; } + /** + * Returns the first point on {@code target}'s bounding box (eyes, head, + * chest, feet, or one of the four upper corners) that has an unobstructed + * block ray-cast from the player's eyes; {@code null} when the entire body + * is occluded. + */ + private static Vec3 firstVisiblePoint(Minecraft mc, Entity target) { + Vec3 eye = mc.player.getEyePosition(); + AABB box = target.getBoundingBox(); + double cx = (box.minX + box.maxX) * 0.5; + double cz = (box.minZ + box.maxZ) * 0.5; + Vec3[] candidates = { + target.getEyePosition(), + new Vec3(cx, (box.minY + box.maxY) * 0.5, cz), + new Vec3(cx, box.maxY - 0.05, cz), + new Vec3(cx, box.minY + 0.1, cz), + new Vec3(box.minX + 0.05, box.maxY - 0.2, box.minZ + 0.05), + new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.minZ + 0.05), + new Vec3(box.minX + 0.05, box.maxY - 0.2, box.maxZ - 0.05), + new Vec3(box.maxX - 0.05, box.maxY - 0.2, box.maxZ - 0.05), + }; + for (Vec3 p : candidates) { + BlockHitResult hit = mc.level.clip(new ClipContext(eye, p, ClipContext.Block.COLLIDER, + ClipContext.Fluid.NONE, mc.player)); + if (hit.getType() == HitResult.Type.MISS + || eye.distanceToSqr(hit.getLocation()) >= eye.distanceToSqr(p) - 1.0E-4) { + return p; + } + } + return null; + } + private static Entity findEntityById(Minecraft mc, int entityId) { for (Entity e : mc.level.entitiesForRendering()) { if (e.getId() == entityId) { diff --git a/src/main/java/com/github/sebseb7/autotrade/gui/MerchantScreenButtonInjector.java b/src/main/java/com/github/sebseb7/autotrade/gui/MerchantScreenButtonInjector.java index a41b770..8621cbe 100644 --- a/src/main/java/com/github/sebseb7/autotrade/gui/MerchantScreenButtonInjector.java +++ b/src/main/java/com/github/sebseb7/autotrade/gui/MerchantScreenButtonInjector.java @@ -12,6 +12,7 @@ import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.fabricmc.fabric.api.client.screen.v1.Screens; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.StringWidget; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.MerchantScreen; import net.minecraft.network.chat.Component; @@ -48,36 +49,55 @@ public final class MerchantScreenButtonInjector { // Position buttons safely to the right of the merchant GUI (276 px wide, centered). int x = scaledWidth / 2 + 140; int y = scaledHeight / 2 - 83; - int w = 160; + int bw = 160; + int lw = 110; int h = 20; int gap = 2; + int labelX = x + bw + 4; Button openSettings = Button - .builder(Component.literal("Open Settings"), btn -> GuiBase.openGui(new GuiConfigs())) - .bounds(x, y, w, h).build(); + .builder(Component.literal("Open Settings"), btn -> { + // vanilla Screen.onClose -> setScreen(null) sends the container-close + // packet; switching the screen out from under the merchant GUI via + // GuiBase.openGui skips that path, so the server still thinks we are + // trading with this villager and rejects the next interact packet. + if (client.player != null) { + client.player.closeContainer(); + } + GuiBase.openGui(new GuiConfigs()); + }) + .bounds(x, y, bw, h).build(); Button selectSell = Button - .builder(sellButtonLabel(), btn -> applySelectedTradeAsSell(client, merchantScreen)) - .bounds(x, y + (h + gap), w, h).build(); + .builder(sellButtonLabel(null), btn -> onSellButton(client, merchantScreen)) + .bounds(x, y + (h + gap), bw, h).build(); + StringWidget sellCurrent = new StringWidget(labelX, y + (h + gap), lw, h, + currentSellLabel(), client.font); Button selectBuy = Button - .builder(buyButtonLabel(), btn -> applySelectedTradeAsBuy(client, merchantScreen)) - .bounds(x, y + 2 * (h + gap), w, h).build(); + .builder(buyButtonLabel(null), btn -> onBuyButton(client, merchantScreen)) + .bounds(x, y + 2 * (h + gap), bw, h).build(); + StringWidget buyCurrent = new StringWidget(labelX, y + 2 * (h + gap), lw, h, + currentBuyLabel(), client.font); Button enableAutotrade = Button .builder(autotradeButtonLabel(), btn -> toggleAutotrade(btn)) - .bounds(x, y + 3 * (h + gap), w, h).build(); + .bounds(x, y + 3 * (h + gap), bw, h).build(); Screen asScreen = merchantScreen; //? if mc26 { Screens.getWidgets(asScreen).add(openSettings); Screens.getWidgets(asScreen).add(selectSell); + Screens.getWidgets(asScreen).add(sellCurrent); Screens.getWidgets(asScreen).add(selectBuy); + Screens.getWidgets(asScreen).add(buyCurrent); Screens.getWidgets(asScreen).add(enableAutotrade); //?} else { Screens.getButtons(asScreen).add(openSettings); Screens.getButtons(asScreen).add(selectSell); + Screens.getButtons(asScreen).add(sellCurrent); Screens.getButtons(asScreen).add(selectBuy); + Screens.getButtons(asScreen).add(buyCurrent); Screens.getButtons(asScreen).add(enableAutotrade); //?} @@ -93,27 +113,74 @@ public final class MerchantScreenButtonInjector { } MerchantOffer current = currentSelectedOffer(merchantScreen); - selectSell.active = current != null && isSellOffer(current); - selectBuy.active = current != null && isBuyOffer(current); - selectSell.setMessage(sellButtonLabel()); - selectBuy.setMessage(buyButtonLabel()); + MerchantOffer sellOffer = (current != null && isSellOffer(current)) ? current : null; + MerchantOffer buyOffer = (current != null && isBuyOffer(current)) ? current : null; + boolean sellOn = Configs.Generic.ENABLE_SELL.getBooleanValue(); + boolean buyOn = Configs.Generic.ENABLE_BUY.getBooleanValue(); + // Button doubles as a "Disable" toggle when no applicable trade is selected + // but the corresponding ENABLE flag is currently on — so the user can turn + // it off without leaving the merchant screen. + selectSell.active = sellOffer != null || sellOn; + selectBuy.active = buyOffer != null || buyOn; + selectSell.setMessage(sellOffer != null ? sellButtonLabel(sellOffer) + : (sellOn ? Component.literal("Disable sell") : sellButtonLabel(null))); + selectBuy.setMessage(buyOffer != null ? buyButtonLabel(buyOffer) + : (buyOn ? Component.literal("Disable buy") : buyButtonLabel(null))); + sellCurrent.setMessage(currentSellLabel()); + buyCurrent.setMessage(currentBuyLabel()); enableAutotrade.setMessage(autotradeButtonLabel()); }); } + private static void onSellButton(Minecraft client, MerchantScreen screen) { + MerchantOffer offer = currentSelectedOffer(screen); + if (offer != null && isSellOffer(offer)) { + applySelectedTradeAsSell(client, screen); + return; + } + if (Configs.Generic.ENABLE_SELL.getBooleanValue()) { + Configs.Generic.ENABLE_SELL.setBooleanValue(false); + Configs.saveToFile(); + } + } + + private static void onBuyButton(Minecraft client, MerchantScreen screen) { + MerchantOffer offer = currentSelectedOffer(screen); + if (offer != null && isBuyOffer(offer)) { + applySelectedTradeAsBuy(client, screen); + return; + } + if (Configs.Generic.ENABLE_BUY.getBooleanValue()) { + Configs.Generic.ENABLE_BUY.setBooleanValue(false); + Configs.saveToFile(); + } + } + private static Component autotradeButtonLabel() { boolean on = Configs.Generic.ENABLED.getBooleanValue(); return Component.literal(on ? "Disable Autotrade" : "Enable Autotrade"); } - private static Component sellButtonLabel() { - String enabled = Configs.Generic.ENABLE_SELL.getBooleanValue() ? "" : " (off)"; - return Component.literal("Sell: " + describeSpec(Configs.Generic.SELL_ITEM.getStringValue()) + enabled); + /** Button label previews the item that would be written if clicked. */ + private static Component sellButtonLabel(MerchantOffer offer) { + String item = offer == null ? "-" : describeSpec(TradeItemSpec.encodeFromStack(offer.getCostA())); + return Component.literal("Set " + item + " as sell item"); } - private static Component buyButtonLabel() { - String enabled = Configs.Generic.ENABLE_BUY.getBooleanValue() ? "" : " (off)"; - return Component.literal("Buy: " + describeSpec(Configs.Generic.BUY_ITEM.getStringValue()) + enabled); + private static Component buyButtonLabel(MerchantOffer offer) { + String item = offer == null ? "-" : describeSpec(TradeItemSpec.encodeFromStack(offer.getResult())); + return Component.literal("Set " + item + " as buy item"); + } + + /** Side-label shows the currently-configured sell/buy item from config. */ + private static Component currentSellLabel() { + String off = Configs.Generic.ENABLE_SELL.getBooleanValue() ? "" : " (off)"; + return Component.literal(describeSpec(Configs.Generic.SELL_ITEM.getStringValue()) + off); + } + + private static Component currentBuyLabel() { + String off = Configs.Generic.ENABLE_BUY.getBooleanValue() ? "" : " (off)"; + return Component.literal(describeSpec(Configs.Generic.BUY_ITEM.getStringValue()) + off); } /**