From 4d64e449a6fbe6c418a8ae05077b7a2c30327a60 Mon Sep 17 00:00:00 2001 From: ASKLL Date: Wed, 14 Oct 2026 18:50:58 +0800 Subject: [PATCH 1/2] feat: add Contract Reroll feature --- docs/IMPLEMENTATION.md | 6 + .../java/pit12/bootstrap/ClientBootstrap.java | 13 + .../contractreroll/ContractRerollConfig.java | 75 +++ .../contractreroll/ContractRerollFeature.java | 539 ++++++++++++++++++ .../pit12/feature/swap/SwapController.java | 26 +- .../java/pit12/feature/swap/SwapFeature.java | 11 +- .../java/pit12/feature/swap/SwapHooks.java | 2 - .../platform/input/LwjglInputDevice.java | 77 ++- .../mixin/feature/swap/MinecraftMixin.java | 1 - .../platform/mixin/input/MinecraftMixin.java | 15 + .../PlayerControllerMPMixin.java | 10 +- .../pit12/runtime/input/InputBlocker.java | 59 +- .../java/pit12/runtime/input/InputDevice.java | 8 +- .../pit12/runtime/input/InputHandler.java | 4 + src/main/java/pit12/shared/input/Keybind.java | 34 +- src/main/resources/mixins.pit12.json | 2 +- 16 files changed, 836 insertions(+), 46 deletions(-) create mode 100644 src/main/java/pit12/feature/contractreroll/ContractRerollConfig.java create mode 100644 src/main/java/pit12/feature/contractreroll/ContractRerollFeature.java rename src/main/java/pit12/platform/mixin/{feature/swap => input}/PlayerControllerMPMixin.java (80%) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 21e4dfc..917c8d4 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -51,8 +51,14 @@ Disk I/O, HTTP requests, expensive computations are done on owned workers with a `InputBlocker` routes key events before Minecraft handles them. Features register an `InputHandler` on start and remove it on stop. `key(...)` returns true when it consumes an event. `consumesKey(...)` identifies keys whose held states must not be restored after blocking ends. +While input is locked, only lock owners receive `blockedKey(key, modifiers)`. It receives new key presses without repeats. Use `Keybind.matches(binding, key, modifiers)` to check a binding against the captured modifier state. The input device queues these presses and dispatches them at tick start, outside LWJGL polling. + Use `lock(owner)` to block keyboard and mouse input. The platform supplies an `InputDevice` that masks LWJGL events and polled states. Minecraft's key, movement, and smooth camera states are cleared once when blocking starts. Use `unlock(owner, resume)` when the operation finishes or is cancelled. Each operation releases only its own lock. `blockedFor(owner)` reports whether another operation holds a lock. Input resumes only after all locks are released. Buffered events are discarded before restoring the input backend. When `resume` is true, held keys are restored if the game has focus. `held(key)` reads the current LWJGL state, which is false while blocked. +Use `runAction(owner, action)` for an automatic right-click or container click while holding the input lock. The action passes through vanilla hooks without restoring physical input. `blocksActions()` tells interaction hooks whether to block a call. Another owner's lock still blocks the action. + +Use `releaseCursor()` while holding a lock to let the cursor leave the game window. `blocksFocus()` keeps menus from grabbing it again until the user clicks inside the window or all input locks are released. Clicking while a menu is open allows focus to return when that menu closes. + ## Config `FeatureConfig` represents the settings of a feature and their interface metadata. Settings are defined in its subclass constructor: diff --git a/src/main/java/pit12/bootstrap/ClientBootstrap.java b/src/main/java/pit12/bootstrap/ClientBootstrap.java index 68150b5..6c7937b 100644 --- a/src/main/java/pit12/bootstrap/ClientBootstrap.java +++ b/src/main/java/pit12/bootstrap/ClientBootstrap.java @@ -34,6 +34,8 @@ import pit12.feature.banalert.BanAlertFeature; import pit12.feature.chatmacro.ChatMacroConfig; import pit12.feature.chatmacro.ChatMacroFeature; +import pit12.feature.contractreroll.ContractRerollConfig; +import pit12.feature.contractreroll.ContractRerollFeature; import pit12.feature.denick.DenickConfig; import pit12.feature.denick.DenickFeature; import pit12.feature.denick.DenickNamesBinding; @@ -178,6 +180,8 @@ public ClientBootstrap() { registerAutoSprint(configs); registerChatMacro(minecraft, configs, session, sidebar, input, commands, new File(minecraft.mcDataDir, "12pit/chat-macros.json").toPath()); + registerContractReroll(minecraft, configs, session, pitContext, sidebar, input, + (MinecraftActions) minecraft); registerNoJumpDelay(configs, (NoJumpDelayBinding) minecraft); // Category: Render registerEventList(configs, hudRegistry, commands); @@ -309,6 +313,15 @@ private void registerChatMacro(Minecraft minecraft, ConfigCatalog configs, input, commands, language, path)); } + private void registerContractReroll(Minecraft minecraft, ConfigCatalog configs, + ClientSession session, PitContext pitContext, SidebarCache sidebar, InputBlocker input, + MinecraftActions actions) { + ContractRerollConfig config = new ContractRerollConfig(); + configs.register(config); + components.add(new ContractRerollFeature(minecraft, configs, config, session, pitContext, + sidebar, input, actions, language)); + } + private void registerNoJumpDelay(ConfigCatalog configs, NoJumpDelayBinding binding) { NoJumpDelayConfig config = new NoJumpDelayConfig(); configs.register(config); diff --git a/src/main/java/pit12/feature/contractreroll/ContractRerollConfig.java b/src/main/java/pit12/feature/contractreroll/ContractRerollConfig.java new file mode 100644 index 0000000..72a0d3d --- /dev/null +++ b/src/main/java/pit12/feature/contractreroll/ContractRerollConfig.java @@ -0,0 +1,75 @@ +/* + * This file is part of 12pit. + * + * Copyright (C) 2026 The 12pit Authors and contributors + * + * 12pit is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 12pit is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 12pit. If not, see . + */ +package pit12.feature.contractreroll; + +import static pit12.runtime.languages.Languages.source; + +import org.lwjgl.input.Keyboard; +import pit12.runtime.config.BooleanSetting; +import pit12.runtime.config.ChoiceSetting; +import pit12.runtime.config.ChoiceSetting.Choice; +import pit12.runtime.config.ConfigCategory; +import pit12.runtime.config.FeatureConfig; +import pit12.runtime.config.KeybindSetting; + +public final class ContractRerollConfig extends FeatureConfig { + final KeybindSetting key; + final BooleanSetting requireViles; + final ChoiceSetting kills; + final ChoiceSetting noArmor; + final ChoiceSetting fist; + final ChoiceSetting chain; + final ChoiceSetting gold; + final ChoiceSetting bow; + final ChoiceSetting noPerks; + final ChoiceSetting apples; + + public ContractRerollConfig() { + super("contractreroll", source("Contract reroll"), + new ConfigCategory("player", source("Player")), + source("Rerolls contracts until one matches your filters."), false); + key = keybindSetting("key", source("Contract reroll key"), + source("Automatically rerolls contracts until one matches your filters."), + Keyboard.KEY_NONE); + requireViles = booleanSetting("require_viles", source("Require bonus viles"), + source("Only accepts matching contracts with a Chunk of Vile bonus."), false); + subsubcategory("tasks", source("Contract filters")); + kills = choiceSetting("kills", source("Kills"), "", 0, new Choice(0, source("Off")), + new Choice(15, source("At most 15")), new Choice(20, source("At most 20"))); + noArmor = choiceSetting("no_armor", source("Kills without armor"), "", 0, + new Choice(0, source("Off")), new Choice(3, source("At most 3")), + new Choice(4, source("At most 4"))); + fist = choiceSetting("fist", source("Fist kills"), "", 0, new Choice(0, source("Off")), + new Choice(1, source("At most 1")), new Choice(2, source("At most 2"))); + chain = choiceSetting("chain", source("Multikills"), "", 0, new Choice(0, source("Off")), + new Choice(3, source("Up to Triple")), new Choice(4, source("Up to Quadra"))); + gold = choiceSetting("gold", source("Ingots"), "", 0, new Choice(0, source("Off")), + new Choice(20, source("At most 20")), new Choice(25, source("At most 25")), + new Choice(30, source("At most 30"))); + bow = choiceSetting("bow", source("Arrow hits"), "", 0, new Choice(0, source("Off")), + new Choice(100, source("At most 100")), new Choice(125, source("At most 125")), + new Choice(150, source("At most 150"))); + noPerks = choiceSetting("no_perks", source("Kills without perks"), "", 0, + new Choice(0, source("Off")), new Choice(8, source("At most 8")), + new Choice(10, source("At most 10"))); + apples = choiceSetting("apples", source("Golden apples"), "", 0, + new Choice(0, source("Off")), new Choice(12, source("At most 12")), + new Choice(16, source("At most 16")), new Choice(18, source("At most 18"))); + } +} diff --git a/src/main/java/pit12/feature/contractreroll/ContractRerollFeature.java b/src/main/java/pit12/feature/contractreroll/ContractRerollFeature.java new file mode 100644 index 0000000..c7145f3 --- /dev/null +++ b/src/main/java/pit12/feature/contractreroll/ContractRerollFeature.java @@ -0,0 +1,539 @@ +/* + * This file is part of 12pit. + * + * Copyright (C) 2026 The 12pit Authors and contributors + * + * 12pit is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 12pit is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 12pit. If not, see . + */ +package pit12.feature.contractreroll; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.gui.inventory.GuiChest; +import net.minecraft.client.renderer.GlStateManager; +import net.minecraft.init.Items; +import net.minecraft.inventory.ContainerChest; +import net.minecraft.inventory.IInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.util.EnumChatFormatting; +import net.minecraft.util.MovingObjectPosition; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.GuiOpenEvent; +import net.minecraftforge.client.event.RenderGameOverlayEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import org.lwjgl.input.Keyboard; +import pit12.runtime.config.ConfigCatalog; +import pit12.runtime.config.ConfigChangeListener; +import pit12.runtime.config.ConfigChangeSet; +import pit12.runtime.input.InputBlocker; +import pit12.runtime.input.InputHandler; +import pit12.runtime.languages.Languages; +import pit12.runtime.pit.PitContext; +import pit12.runtime.pit.PitState; +import pit12.runtime.scoreboard.SidebarCache; +import pit12.runtime.scoreboard.SidebarLine; +import pit12.runtime.scoreboard.SidebarListener; +import pit12.runtime.session.ClientSession; +import pit12.shared.chat.ChatFeedback; +import pit12.shared.chat.ChatFeedback.Tone; +import pit12.shared.input.Keybind; +import pit12.shared.input.MinecraftActions; +import pit12.shared.lifecycle.ClientLifecycle; + +public final class ContractRerollFeature + implements ClientLifecycle, InputHandler, SidebarListener, ConfigChangeListener { + private static final Logger LOGGER = Logger.getLogger(ContractRerollFeature.class.getName()); + private static final int[] CHOICE_SLOTS = {11, 13, 15}; + private static final Pattern KILLS = Pattern.compile("Kill ([0-9]+) players\\."); + private static final Pattern NO_ARMOR = + Pattern.compile("Kill ([0-9]+) players without wearing any armor\\."); + private static final Pattern FIST = Pattern.compile( + "Get ([0-9]+) killing blows using your fist inside the small sea lantern circle of the pit\\."); + private static final Pattern INGOTS = Pattern.compile("Collect ([0-9]+) ingots"); + private static final Pattern ARROWS = + Pattern.compile("Land ([0-9]+) arrow shots on enemies\\."); + private static final Pattern NO_PERKS = + Pattern.compile("Kill ([0-9]+) players without any perks equipped\\."); + private static final Pattern APPLES = + Pattern.compile("Eat ([0-9]+) golden apples \\(no golden heads\\)\\."); + private final Minecraft minecraft; + private final ConfigCatalog configs; + private final ContractRerollConfig config; + private final ClientSession session; + private final PitContext pit; + private final SidebarCache sidebar; + private final InputBlocker input; + private final MinecraftActions actions; + private final Languages language; + private final Runnable sessionChanged = () -> finish(false); + private final Runnable languageChanged = this::updateHint; + private boolean started; + private State state = State.IDLE; + private EntityPlayerSP player; + private boolean hasContract; + private boolean previousPauseOnLostFocus; + private GuiScreen previousScreen; + private GuiChest menu; + private ItemStack[] pendingStacks; + private long deadline; + private long clickAt; + private String hint; + + public ContractRerollFeature(Minecraft minecraft, ConfigCatalog configs, + ContractRerollConfig config, ClientSession session, PitContext pit, + SidebarCache sidebar, InputBlocker input, MinecraftActions actions, + Languages language) { + this.minecraft = minecraft; + this.configs = configs; + this.config = config; + this.session = session; + this.pit = pit; + this.sidebar = sidebar; + this.input = input; + this.actions = actions; + this.language = language; + } + + @Override + public void start() { + session.checkThread(); + if (started) + return; + started = true; + input.addHandler(this); + configs.addListener(this); + } + + @Override + public void stop() { + session.checkThread(); + started = false; + finish(false); + input.removeHandler(this); + configs.removeListener(this); + } + + @Override + public boolean consumesKey(int key) { + return Keybind.matches(config.key.get(), key); + } + + @Override + public boolean blockedKey(int key, int modifiers) { + if (Keybind.matches(config.key.get(), key, modifiers)) { + finish(true); + return true; + } + if (key == Keyboard.KEY_ESCAPE) { + input.releaseCursor(); + return true; + } + return false; + } + + @Override + public boolean key(int key, boolean pressed, boolean repeat) { + if (!pressed || !consumesKey(key) || minecraft.currentScreen != null + || !minecraft.inGameHasFocus || minecraft.thePlayer == null + || minecraft.theWorld == null) + return false; + if (repeat || state != State.IDLE) + return true; + if (pit.current().pitState() != PitState.IN_PIT) { + report(language.translate("Start rerolling contracts in the Pit.")); + return true; + } + try { + player = minecraft.thePlayer; + previousPauseOnLostFocus = minecraft.gameSettings.pauseOnLostFocus; + minecraft.gameSettings.pauseOnLostFocus = false; + enter(State.LANGUAGE, Minecraft.getSystemTime()); + session.addListener(sessionChanged); + sidebar.addListener(this); + language.addListener(languageChanged); + refreshContract(); + MinecraftForge.EVENT_BUS.register(this); + input.lock(this); + clickAt = Minecraft.getSystemTime() + ThreadLocalRandom.current().nextInt(150, 351); + } catch (RuntimeException failure) { + failedAction(failure); + } + return true; + } + + @SubscribeEvent + public void onTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END || state == State.IDLE) + return; + if (player != minecraft.thePlayer || minecraft.theWorld == null || player.isDead) { + finish(false); + return; + } + if (pit.current().pitState() != PitState.IN_PIT) { + fail(language.translate("You left the Pit.")); + return; + } + if (input.blockedFor(this)) { + fail(language.translate("Another action took control.")); + return; + } + long now = Minecraft.getSystemTime(); + if (now >= deadline) { + fail(state == State.QUESTS + ? language.translate( + "Move next to the Quest Master and aim at them before starting.") + : language.translate("Timed out waiting for the server.")); + return; + } + try { + if (!checkScreen(minecraft.currentScreen)) + return; + switch (state) { + case LANGUAGE: + if (now >= clickAt) { + enter(State.LANGUAGE_REPLY, now); + player.sendChatMessage("/lang english"); + } + break; + case CONTRACT: + if (!hasContract) { + if (clickAt == 0) + clickAt = now + ThreadLocalRandom.current().nextInt(150, 351); + else if (now >= clickAt) + openQuests(now); + } else { + clickAt = 0; + } + break; + case QUESTS: + case PURCHASE: + case START: + if (menu != null) + clickMenu(now); + break; + case CHOICES: + if (menu != null) + pickContract(now); + break; + case CONTRACT_START: + if (minecraft.currentScreen == null && hasContract) + enter(State.CONTRACT, now); + break; + default: + break; + } + } catch (RuntimeException failure) { + failedAction(failure); + } + } + + @SubscribeEvent + public void onChat(ClientChatReceivedEvent event) { + if (state == State.LANGUAGE_REPLY && event.type != 2 + && EnumChatFormatting + .getTextWithoutFormattingCodes(event.message.getUnformattedText()).trim() + .equals("Selected language: English")) { + refreshContract(); + enter(State.CONTRACT, Minecraft.getSystemTime()); + } + } + + @Override + public void onLineChanged(SidebarLine previous, SidebarLine current) { + if (state != State.IDLE) { + refreshContract(); + if (hasContract && state.title != null) + fail(language.translate("A contract started unexpectedly.")); + } + } + + private void refreshContract() { + hasContract = false; + for (SidebarLine line : sidebar.lines()) { + String text = line.formattedText(minecraft.fontRendererObj); + if (text.contains("Contract") || text.contains("合同")) { + hasContract = true; + break; + } + } + } + + private void openQuests(long now) { + MovingObjectPosition hit = minecraft.objectMouseOver; + if (hit == null || hit.typeOfHit != MovingObjectPosition.MovingObjectType.ENTITY + || hit.entityHit == null) { + fail(language + .translate("Move next to the Quest Master and aim at them before starting.")); + return; + } + if (player.inventory.getItemStack() != null) { + fail(language.translate("The inventory changed.")); + return; + } + enter(State.QUESTS, now); + input.runAction(this, actions::pit12$rightClick); + } + + @SubscribeEvent + public void onGuiOpen(GuiOpenEvent event) { + if (state == State.IDLE) + return; + if (event.gui == null) { + if (menu != null) + fail(language.translate("The menu closed unexpectedly.")); + } else if (event.gui != previousScreen && !acceptMenu(event.gui)) { + fail(state == State.QUESTS + ? language.translate( + "Move next to the Quest Master and aim at them before starting.") + : language.translate("An unexpected menu opened.")); + } + } + + private boolean checkScreen(GuiScreen screen) { + if (menu == null && (screen == null || screen == previousScreen)) + return true; + if (!acceptMenu(screen) || menu != null && menu != screen) { + fail(language.translate("The menu changed unexpectedly.")); + return false; + } + menu = (GuiChest) screen; + if (player.openContainer != menu.inventorySlots) { + fail(language.translate("The inventory changed.")); + return false; + } + return true; + } + + private boolean acceptMenu(GuiScreen screen) { + if (state.title == null || !(screen instanceof GuiChest) + || !(((GuiChest) screen).inventorySlots instanceof ContainerChest)) + return false; + IInventory inventory = + ((ContainerChest) ((GuiChest) screen).inventorySlots).getLowerChestInventory(); + String title = EnumChatFormatting + .getTextWithoutFormattingCodes(inventory.getDisplayName().getUnformattedText()); + if (state == State.PURCHASE && inventory.getSizeInventory() == State.CHOICES.slots + && State.CHOICES.title.equals(title)) + enter(State.CHOICES, Minecraft.getSystemTime()); + return inventory.getSizeInventory() == state.slots && state.title.equals(title); + } + + private void clickMenu(long now) { + int slot = state == State.QUESTS ? 32 : 11; + ItemStack stack = menu.inventorySlots.getSlot(slot).getStack(); + if (state == State.QUESTS && stack == null) { + slot = 31; + stack = menu.inventorySlots.getSlot(slot).getStack(); + } + if (stack == null) { + clickAt = 0; + pendingStacks = null; + return; + } + if (pendingStacks == null || !ItemStack.areItemStacksEqual(pendingStacks[0], stack)) { + pendingStacks = new ItemStack[] {stack.copy()}; + clickAt = now + ThreadLocalRandom.current().nextInt(150, 351); + return; + } + if (now < clickAt) + return; + click(slot, state == State.QUESTS ? State.PURCHASE + : state == State.PURCHASE ? State.CHOICES : State.CONTRACT_START, now); + } + + private void pickContract(long now) { + IInventory inventory = ((ContainerChest) menu.inventorySlots).getLowerChestInventory(); + for (int slot = 0; slot < inventory.getSizeInventory(); slot++) { + ItemStack stack = inventory.getStackInSlot(slot); + if (slot == 11 || slot == 13 || slot == 15) { + if (stack != null && stack.getItem() == Items.enchanted_book) + continue; + } else if (stack == null) { + continue; + } + pendingStacks = null; + clickAt = 0; + return; + } + boolean changed = pendingStacks == null; + if (!changed) { + for (int index = 0; index < CHOICE_SLOTS.length; index++) + changed |= !ItemStack.areItemStacksEqual(pendingStacks[index], + inventory.getStackInSlot(CHOICE_SLOTS[index])); + } + if (changed) { + pendingStacks = new ItemStack[CHOICE_SLOTS.length]; + for (int index = 0; index < CHOICE_SLOTS.length; index++) + pendingStacks[index] = inventory.getStackInSlot(CHOICE_SLOTS[index]).copy(); + clickAt = now + ThreadLocalRandom.current().nextInt(150, 351); + return; + } + if (now < clickAt) + return; + String[] tooltips = new String[CHOICE_SLOTS.length]; + for (int index = 0; index < tooltips.length; index++) { + tooltips[index] = EnumChatFormatting + .getTextWithoutFormattingCodes( + String.join(" ", pendingStacks[index].getTooltip(player, false))) + .replaceAll("\\s+", " "); + } + for (int index = 0; index < tooltips.length; index++) { + if (config.requireViles.get() && !tooltips[index].contains("Bonus: 1 Chunk of Vile") + && !tooltips[index].contains("Bonus: 2 Chunk of Vile")) + continue; + if (matchesTask(tooltips[index])) { + click(CHOICE_SLOTS[index], State.START, now); + finish(true); + return; + } + } + click(CHOICE_SLOTS[ThreadLocalRandom.current().nextInt(CHOICE_SLOTS.length)], State.START, + now); + } + + private boolean matchesTask(String tooltip) { + return matchesCount(tooltip, KILLS, config.kills.get()) + || matchesCount(tooltip, NO_ARMOR, config.noArmor.get()) + || matchesCount(tooltip, FIST, config.fist.get()) + || config.chain.get() >= 2 && tooltip.contains("Get a Double kill.") + || config.chain.get() >= 3 && tooltip.contains("Get a Triple kill.") + || config.chain.get() >= 4 && tooltip.contains("Get a Quadra kill.") + || matchesCount(tooltip, INGOTS, config.gold.get()) + || matchesCount(tooltip, ARROWS, config.bow.get()) + || matchesCount(tooltip, NO_PERKS, config.noPerks.get()) + || matchesCount(tooltip, APPLES, config.apples.get()); + } + + private static boolean matchesCount(String tooltip, Pattern task, int maximum) { + if (maximum == 0) + return false; + Matcher match = task.matcher(tooltip); + if (!match.find()) + return false; + try { + return Integer.parseInt(match.group(1)) <= maximum; + } catch (NumberFormatException invalidCount) { + return false; + } + } + + private void click(int slot, State next, long now) { + if (player.inventory.getItemStack() != null) { + fail(language.translate("The inventory changed.")); + return; + } + int windowId = menu.inventorySlots.windowId; + enter(next, now); + input.runAction(this, + () -> minecraft.playerController.windowClick(windowId, slot, 0, 0, player)); + } + + private void enter(State next, long now) { + state = next; + previousScreen = minecraft.currentScreen; + menu = null; + pendingStacks = null; + clickAt = 0; + deadline = now + (next == State.CONTRACT ? 600_000 : 10_000); + updateHint(); + } + + private void updateHint() { + hint = state == State.CONTRACT ? language.translate("Waiting for the contract to end…") + : language.translate("Rerolling contracts…"); + } + + @Override + public void onConfigChanged(ConfigChangeSet changes) { + if (changes.affects("contractreroll", "key") && config.key.get() == Keyboard.KEY_NONE) + finish(true); + } + + private void failedAction(RuntimeException failure) { + LOGGER.log(Level.WARNING, "Contract reroll failed", failure); + fail(language.translate("An unexpected error occurred.")); + } + + private void fail(String reason) { + finish(true); + report(language.format("Contract reroll stopped: {0}", reason)); + } + + private void report(String message) { + if (minecraft.thePlayer != null) + ChatFeedback.reply(minecraft.thePlayer, Tone.ERROR, message); + } + + private void finish(boolean resume) { + if (state != State.IDLE) + minecraft.gameSettings.pauseOnLostFocus = previousPauseOnLostFocus; + state = State.IDLE; + player = null; + previousScreen = null; + menu = null; + pendingStacks = null; + hint = null; + MinecraftForge.EVENT_BUS.unregister(this); + session.removeListener(sessionChanged); + sidebar.removeListener(this); + language.removeListener(languageChanged); + input.unlock(this, resume); + } + + @SubscribeEvent + public void onRenderOverlay(RenderGameOverlayEvent.Post event) { + if (state == State.IDLE || event.type != RenderGameOverlayEvent.ElementType.ALL + || minecraft.gameSettings.hideGUI || minecraft.thePlayer == null) + return; + GlStateManager.pushMatrix(); + GlStateManager.translate(event.resolution.getScaledWidth() / 2.0F, + event.resolution.getScaledHeight() / 2.0F + 35, 0); + GlStateManager.scale(2, 2, 2); + minecraft.fontRendererObj.drawStringWithShadow(hint, + -minecraft.fontRendererObj.getStringWidth(hint) / 2.0F, 0, 0xFFFFFF); + GlStateManager.popMatrix(); + } + + private enum State { + IDLE, + LANGUAGE, + LANGUAGE_REPLY, + CONTRACT, + QUESTS("Quests & Contracts", 45), + PURCHASE("Are you sure?", 27), + CHOICES("Pick a contract!", 27), + START("Start event?", 27), + CONTRACT_START; + + final String title; + final int slots; + + State() { + this(null, 0); + } + + State(String title, int slots) { + this.title = title; + this.slots = slots; + } + } +} diff --git a/src/main/java/pit12/feature/swap/SwapController.java b/src/main/java/pit12/feature/swap/SwapController.java index 9f3b543..ce3b85d 100644 --- a/src/main/java/pit12/feature/swap/SwapController.java +++ b/src/main/java/pit12/feature/swap/SwapController.java @@ -31,7 +31,6 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.function.BiConsumer; import java.util.function.BooleanSupplier; -import java.util.function.Consumer; import net.minecraft.client.Minecraft; import net.minecraft.client.entity.EntityPlayerSP; import net.minecraft.client.gui.GuiScreen; @@ -40,6 +39,7 @@ import net.minecraft.item.ItemStack; import net.minecraft.network.play.client.C16PacketClientStatus; import net.minecraft.util.EnumChatFormatting; +import pit12.runtime.input.InputBlocker; import pit12.runtime.item.PitEnchantmentReader; import pit12.runtime.languages.Languages; import pit12.runtime.session.ClientSession; @@ -52,8 +52,8 @@ final class SwapController { private final SwapConfig config; private final BindingBook bindings; private final BiConsumer report; - private final Runnable lockInput; - private final Consumer releaseInput; + private final InputBlocker input; + private final Object inputOwner; private final Deque queue = new ArrayDeque<>(); private final Set accepted = new HashSet<>(); private final Deque actions = new ArrayDeque<>(); @@ -79,16 +79,16 @@ final class SwapController { private String rejection; SwapController(Minecraft minecraft, ClientSession session, SwapConfig config, - BindingBook bindings, BiConsumer report, Runnable lockInput, - Consumer releaseInput, Languages language) { + BindingBook bindings, BiConsumer report, InputBlocker input, + Object inputOwner, Languages language) { this.language = language; this.minecraft = minecraft; this.session = session; this.config = config; this.bindings = bindings; this.report = report; - this.lockInput = lockInput; - this.releaseInput = releaseInput; + this.input = input; + this.inputOwner = inputOwner; } boolean acceptsInput() { @@ -160,7 +160,7 @@ private void enqueue(Request request, Object identity) { options = new SwapConfig.Options(config); player = minecraft.thePlayer; sessionRevision = session.revision(); - lockInput.run(); + input.lock(inputOwner); } queue.addLast(request); closeAt = -1; @@ -277,10 +277,6 @@ void clickSent(int windowId, short actionNumber) { pendingClicks.add(actionNumber); } - boolean sendingClick() { - return sendingClick; - } - void confirmClick(int windowId, short actionNumber, boolean accepted) { if (options == null || player != minecraft.thePlayer || windowId != player.inventoryContainer.windowId @@ -402,8 +398,8 @@ private void click(int slot, int button, int mode) { } sendingClick = true; try { - minecraft.playerController.windowClick(player.inventoryContainer.windowId, slot, button, - mode, player); + input.runAction(inputOwner, () -> minecraft.playerController + .windowClick(player.inventoryContainer.windowId, slot, button, mode, player)); } finally { sendingClick = false; } @@ -582,7 +578,7 @@ private void reset() { waitingToResume = false; rejection = null; if (active) - releaseInput.accept(resume); + input.unlock(inputOwner, resume); } private static ItemStack copy(ItemStack stack) { diff --git a/src/main/java/pit12/feature/swap/SwapFeature.java b/src/main/java/pit12/feature/swap/SwapFeature.java index 45e4bde..7d65a9a 100644 --- a/src/main/java/pit12/feature/swap/SwapFeature.java +++ b/src/main/java/pit12/feature/swap/SwapFeature.java @@ -81,8 +81,8 @@ public SwapFeature(Minecraft minecraft, ClientThread client, ConfigCatalog confi this.input = input; bindings = new BindingBook(client, path, message -> report(Tone.ERROR, language.translate(message))); - controller = new SwapController(minecraft, session, config, bindings, this::report, - () -> input.lock(this), resume -> input.unlock(this, resume), language); + controller = new SwapController(minecraft, session, config, bindings, this::report, input, + this, language); DarkTargets darkTargets = new DarkTargets(minecraft, config, equipment, relations); automatic = new AutoSwapController(minecraft, config, controller, pit, darkTargets, language); @@ -162,11 +162,6 @@ private void worldChanged() { updateListening(); } - @Override - public boolean inventoryClickAllowed() { - return controller.sendingClick(); - } - @Override public boolean consumesKey(int key) { return config.enabled() @@ -193,7 +188,7 @@ public boolean key(int key, boolean pressed, boolean repeat) { @Override public boolean rightClick() { if (input.locked()) - return true; + return false; if (rightClickHeld) return true; if (!config.enabled() || !controller.rightClickEnabled() || minecraft.currentScreen != null diff --git a/src/main/java/pit12/feature/swap/SwapHooks.java b/src/main/java/pit12/feature/swap/SwapHooks.java index 07e7b84..55e315f 100644 --- a/src/main/java/pit12/feature/swap/SwapHooks.java +++ b/src/main/java/pit12/feature/swap/SwapHooks.java @@ -21,8 +21,6 @@ import net.minecraft.item.ItemStack; public interface SwapHooks { - boolean inventoryClickAllowed(); - boolean rightClick(); // 0 = tick start, 1-3 = input phases. diff --git a/src/main/java/pit12/platform/input/LwjglInputDevice.java b/src/main/java/pit12/platform/input/LwjglInputDevice.java index 8c434ad..930da0f 100644 --- a/src/main/java/pit12/platform/input/LwjglInputDevice.java +++ b/src/main/java/pit12/platform/input/LwjglInputDevice.java @@ -23,10 +23,13 @@ import java.lang.reflect.Proxy; import java.nio.ByteBuffer; import java.nio.IntBuffer; +import java.util.ArrayDeque; +import java.util.Deque; import org.lwjgl.input.Keyboard; import org.lwjgl.input.Mouse; import org.lwjgl.opengl.InputImplementation; import pit12.runtime.input.InputDevice; +import pit12.shared.input.Keybind; public final class LwjglInputDevice implements InputDevice { private final Object lock; @@ -36,6 +39,10 @@ public final class LwjglInputDevice implements InputDevice { private InputImplementation keyboardBlocked; private InputImplementation mouseOriginal; private InputImplementation mouseBlocked; + private final Deque presses = new ArrayDeque<>(); + private KeyPressHandler keyPressHandler; + private Runnable mousePressHandler; + private int modifiers; public LwjglInputDevice() { try { @@ -55,8 +62,19 @@ public LwjglInputDevice() { } @Override - public void block() { + public void block(KeyPressHandler handler, Runnable mousePressHandler) { synchronized (lock) { + keyPressHandler = handler; + this.mousePressHandler = mousePressHandler; + modifiers = 0; + if (Keyboard.isCreated()) { + for (int key : new int[] {Keyboard.KEY_LSHIFT, Keyboard.KEY_RSHIFT, + Keyboard.KEY_LCONTROL, Keyboard.KEY_RCONTROL, Keyboard.KEY_LMENU, + Keyboard.KEY_RMENU, Keyboard.KEY_LMETA, Keyboard.KEY_RMETA}) { + if (Keyboard.isKeyDown(key)) + modifiers |= Keybind.modifierMask(key); + } + } try { if (Keyboard.isCreated()) { InputImplementation original = (InputImplementation) keyboardBackend.get(null); @@ -75,15 +93,33 @@ public void block() { mouseBackend.set(null, mouseBlocked); } pollAndDrain(); + presses.clear(); } catch (IllegalAccessException exception) { throw new IllegalStateException("Could not block LWJGL input", exception); } } } + @Override + public void dispatchEvents() { + // Cancellation restores the backend, so run callbacks outside LWJGL polling. + while (true) { + Runnable press; + synchronized (lock) { + press = presses.pollFirst(); + } + if (press == null) + return; + press.run(); + } + } + @Override public void unblock() { synchronized (lock) { + keyPressHandler = null; + mousePressHandler = null; + presses.clear(); try { // Don't replay blocked events when input comes back. try { @@ -101,7 +137,7 @@ public void unblock() { } } - private static InputImplementation mask(InputImplementation original) { + private InputImplementation mask(InputImplementation original) { return (InputImplementation) Proxy.newProxyInstance( InputImplementation.class.getClassLoader(), new Class[] {InputImplementation.class}, (proxy, method, arguments) -> { @@ -121,14 +157,43 @@ private static InputImplementation mask(InputImplementation original) { coordinates.put(2, 0); return null; case "readKeyboard": + ByteBuffer keyboardEvents = (ByteBuffer) arguments[0]; + do { + keyboardEvents.clear(); + original.readKeyboard(keyboardEvents); + for (int offset = 0; offset < keyboardEvents.position(); offset += + Keyboard.EVENT_SIZE) { + // LWJGL 2 stores key, state, character, time, then repeat. + int key = keyboardEvents.getInt(offset) & 0xFF; + boolean pressed = keyboardEvents.get(offset + 4) != 0; + int modifier = Keybind.modifierMask(key); + if (pressed) + modifiers |= modifier; + else + modifiers &= ~modifier; + if (keyPressHandler != null && pressed + && keyboardEvents.get(offset + 17) != 1) { + KeyPressHandler handler = keyPressHandler; + int heldModifiers = modifiers; + presses.addLast(() -> handler.key(key, heldModifiers)); + } + } + } while (keyboardEvents.position() != 0); + return null; case "readMouse": ByteBuffer events = (ByteBuffer) arguments[0]; do { events.clear(); - if (method.getName().equals("readKeyboard")) - original.readKeyboard(events); - else - original.readMouse(events); + original.readMouse(events); + if (mousePressHandler != null && !Mouse.isGrabbed() + && Mouse.isInsideWindow()) { + for (int offset = 0; offset < events.position(); offset += + Mouse.EVENT_SIZE) { + // LWJGL 2 starts each mouse event with button and state. + if (events.get(offset) >= 0 && events.get(offset + 1) != 0) + presses.addLast(mousePressHandler); + } + } } while (events.position() != 0); return null; default: diff --git a/src/main/java/pit12/platform/mixin/feature/swap/MinecraftMixin.java b/src/main/java/pit12/platform/mixin/feature/swap/MinecraftMixin.java index 18d69d5..b334b03 100644 --- a/src/main/java/pit12/platform/mixin/feature/swap/MinecraftMixin.java +++ b/src/main/java/pit12/platform/mixin/feature/swap/MinecraftMixin.java @@ -43,7 +43,6 @@ public abstract class MinecraftMixin implements SwapHooksBinding { return pit12$swapHooks; } - // Block interaction packets are sent before Forge's right-click-air event. @Inject(method = "rightClickMouse", at = @At("HEAD"), cancellable = true) private void pit12$swapRightClick(CallbackInfo callback) { if (pit12$swapHooks != null && pit12$swapHooks.rightClick()) diff --git a/src/main/java/pit12/platform/mixin/input/MinecraftMixin.java b/src/main/java/pit12/platform/mixin/input/MinecraftMixin.java index c374dc9..75ab5cd 100644 --- a/src/main/java/pit12/platform/mixin/input/MinecraftMixin.java +++ b/src/main/java/pit12/platform/mixin/input/MinecraftMixin.java @@ -23,7 +23,9 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import pit12.runtime.input.InputBlockBinding; import pit12.runtime.input.InputBlocker; @@ -42,6 +44,19 @@ public abstract class MinecraftMixin implements InputBlockBinding { return pit12$inputBlocker; } + // Block interaction packets are sent before Forge's right-click-air event. + @Inject(method = "rightClickMouse", at = @At("HEAD"), cancellable = true) + private void pit12$blockRightClick(CallbackInfo callback) { + if (pit12$inputBlocker != null && pit12$inputBlocker.blocksActions()) + callback.cancel(); + } + + @Inject(method = "setIngameFocus", at = @At("HEAD"), cancellable = true) + private void pit12$keepCursorFree(CallbackInfo callback) { + if (pit12$inputBlocker != null && pit12$inputBlocker.blocksFocus()) + callback.cancel(); + } + @Redirect(method = "runTick", at = @At(value = "INVOKE", target = "Lorg/lwjgl/input/Keyboard;next()Z", remap = false)) private boolean pit12$nextKeyboardEvent() { diff --git a/src/main/java/pit12/platform/mixin/feature/swap/PlayerControllerMPMixin.java b/src/main/java/pit12/platform/mixin/input/PlayerControllerMPMixin.java similarity index 80% rename from src/main/java/pit12/platform/mixin/feature/swap/PlayerControllerMPMixin.java rename to src/main/java/pit12/platform/mixin/input/PlayerControllerMPMixin.java index 398ea17..aae7fad 100644 --- a/src/main/java/pit12/platform/mixin/feature/swap/PlayerControllerMPMixin.java +++ b/src/main/java/pit12/platform/mixin/input/PlayerControllerMPMixin.java @@ -16,7 +16,7 @@ * You should have received a copy of the GNU General Public License * along with 12pit. If not, see . */ -package pit12.platform.mixin.feature.swap; +package pit12.platform.mixin.input; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.PlayerControllerMP; @@ -27,8 +27,6 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import pit12.feature.swap.SwapHooks; -import pit12.feature.swap.SwapHooksBinding; import pit12.runtime.input.InputBlockBinding; import pit12.runtime.input.InputBlocker; @@ -39,11 +37,9 @@ public abstract class PlayerControllerMPMixin { private Minecraft mc; @Inject(method = "windowClick", at = @At("HEAD"), cancellable = true) - private void pit12$blockOtherInventoryClicks(CallbackInfoReturnable callback) { - SwapHooks hooks = ((SwapHooksBinding) mc).pit12$swapHooks(); + private void pit12$blockInventoryClicks(CallbackInfoReturnable callback) { InputBlocker blocker = ((InputBlockBinding) mc).pit12$inputBlocker(); - if (blocker != null && blocker.locked() - && (hooks == null || !hooks.inventoryClickAllowed())) + if (blocker != null && blocker.blocksActions()) callback.setReturnValue(null); } } diff --git a/src/main/java/pit12/runtime/input/InputBlocker.java b/src/main/java/pit12/runtime/input/InputBlocker.java index 69f23b5..00199cf 100644 --- a/src/main/java/pit12/runtime/input/InputBlocker.java +++ b/src/main/java/pit12/runtime/input/InputBlocker.java @@ -25,6 +25,10 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.entity.EntityPlayerSP; import net.minecraft.client.settings.KeyBinding; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.common.eventhandler.EventPriority; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; import org.lwjgl.input.Keyboard; import org.lwjgl.input.Mouse; import org.lwjgl.opengl.Display; @@ -38,6 +42,8 @@ public final class InputBlocker implements ClientLifecycle { private final InputDevice device; private final Set owners = new HashSet<>(); private final List handlers = new ArrayList<>(); + private Object actionOwner; + private boolean cursorReleased; private boolean started; public InputBlocker(Minecraft minecraft, ClientThread client, InputBlockBinding binding, @@ -86,7 +92,8 @@ public void lock(Object owner) { owners.add(owner); if (alreadyLocked) return; - device.block(); + device.block(this::blockedKey, this::focusOnClick); + MinecraftForge.EVENT_BUS.register(this); KeyBinding.unPressAllKeys(); if (minecraft.playerController != null) { minecraft.playerController.resetBlockRemoving(); @@ -124,6 +131,41 @@ public boolean blockedFor(Object owner) { return locked() && (owners.size() != 1 || !owners.contains(owner)); } + public boolean blocksActions() { + return locked() && (actionOwner == null || blockedFor(actionOwner)); + } + + public void releaseCursor() { + client.check(); + cursorReleased = true; + minecraft.setIngameNotInFocus(); + } + + public boolean blocksFocus() { + return locked() && cursorReleased; + } + + private void focusOnClick() { + if (!cursorReleased || !Display.isActive()) + return; + cursorReleased = false; + // setIngameFocus closes the current menu. + if (minecraft.currentScreen == null) + minecraft.setIngameFocus(); + } + + public void runAction(Object owner, Runnable action) { + client.check(); + // Let the lock owner use vanilla interaction hooks without restoring physical input. + Object previous = actionOwner; + actionOwner = owner; + try { + action.run(); + } finally { + actionOwner = previous; + } + } + public boolean key(int key, boolean pressed, boolean repeat) { if (locked()) return true; @@ -134,7 +176,22 @@ public boolean key(int key, boolean pressed, boolean repeat) { return false; } + @SubscribeEvent(priority = EventPriority.HIGHEST) + public void onTick(TickEvent.ClientTickEvent event) { + if (event.phase == TickEvent.Phase.START) + device.dispatchEvents(); + } + + private void blockedKey(int key, int modifiers) { + for (Object owner : owners) { + if (owner instanceof InputHandler && ((InputHandler) owner).blockedKey(key, modifiers)) + return; + } + } + private void release(boolean resume) { + MinecraftForge.EVENT_BUS.unregister(this); + cursorReleased = false; device.unblock(); minecraft.mouseHelper.deltaX = 0; minecraft.mouseHelper.deltaY = 0; diff --git a/src/main/java/pit12/runtime/input/InputDevice.java b/src/main/java/pit12/runtime/input/InputDevice.java index 939a76a..578b235 100644 --- a/src/main/java/pit12/runtime/input/InputDevice.java +++ b/src/main/java/pit12/runtime/input/InputDevice.java @@ -19,7 +19,13 @@ package pit12.runtime.input; public interface InputDevice { - void block(); + interface KeyPressHandler { + void key(int key, int modifiers); + } + + void block(KeyPressHandler handler, Runnable mousePressHandler); + + void dispatchEvents(); void unblock(); } diff --git a/src/main/java/pit12/runtime/input/InputHandler.java b/src/main/java/pit12/runtime/input/InputHandler.java index 4a3f4ee..f9fc8ab 100644 --- a/src/main/java/pit12/runtime/input/InputHandler.java +++ b/src/main/java/pit12/runtime/input/InputHandler.java @@ -22,4 +22,8 @@ public interface InputHandler { boolean key(int key, boolean pressed, boolean repeat); boolean consumesKey(int key); + + default boolean blockedKey(int key, int modifiers) { + return false; + } } diff --git a/src/main/java/pit12/shared/input/Keybind.java b/src/main/java/pit12/shared/input/Keybind.java index 2ecbe77..3552c46 100644 --- a/src/main/java/pit12/shared/input/Keybind.java +++ b/src/main/java/pit12/shared/input/Keybind.java @@ -37,10 +37,30 @@ public static int modifier(int binding) { } public static boolean isModifier(int key) { - return key == Keyboard.KEY_LSHIFT || key == Keyboard.KEY_RSHIFT - || key == Keyboard.KEY_LCONTROL || key == Keyboard.KEY_RCONTROL - || key == Keyboard.KEY_LMENU || key == Keyboard.KEY_RMENU - || key == Keyboard.KEY_LMETA || key == Keyboard.KEY_RMETA; + return modifierMask(key) != 0; + } + + public static int modifierMask(int key) { + switch (key) { + case Keyboard.KEY_LSHIFT: + return 1; + case Keyboard.KEY_RSHIFT: + return 2; + case Keyboard.KEY_LCONTROL: + return 4; + case Keyboard.KEY_RCONTROL: + return 8; + case Keyboard.KEY_LMENU: + return 16; + case Keyboard.KEY_RMENU: + return 32; + case Keyboard.KEY_LMETA: + return 64; + case Keyboard.KEY_RMETA: + return 128; + default: + return 0; + } } public static boolean valid(int binding) { @@ -77,6 +97,12 @@ public static boolean matches(int binding, int eventKey) { && (modifier == 0 || Keyboard.isCreated() && Keyboard.isKeyDown(modifier)); } + public static boolean matches(int binding, int eventKey, int modifiers) { + int modifier = modifier(binding); + return eventKey != Keyboard.KEY_NONE && key(binding) == eventKey + && (modifier == 0 || (modifiers & modifierMask(modifier)) != 0); + } + public static boolean held(int binding) { int key = key(binding); return key != Keyboard.KEY_NONE && Keyboard.isCreated() && Keyboard.isKeyDown(key) diff --git a/src/main/resources/mixins.pit12.json b/src/main/resources/mixins.pit12.json index 3af106e..f8f2707 100644 --- a/src/main/resources/mixins.pit12.json +++ b/src/main/resources/mixins.pit12.json @@ -18,6 +18,7 @@ "world.WorldClientMixin", "input.MinecraftMixin", "input.EntityRendererMixin", + "input.PlayerControllerMPMixin", "feature.autoclick.MinecraftMixin", "feature.denick.GuiPlayerTabOverlayMixin", "feature.gamma.EntityRendererMixin", @@ -30,7 +31,6 @@ "feature.smartblock.MinecraftMixin", "feature.tooltip.GuiIngameForgeMixin", "feature.swap.MinecraftMixin", - "feature.swap.PlayerControllerMPMixin", "feature.swap.NetHandlerPlayClientMixin", "feature.swap.GuiContainerMixin", "feature.swap.GuiIngameMixin" From 49ba7079c350af5f046cd51f01f723127e258ecc Mon Sep 17 00:00:00 2001 From: ASKLL Date: Wed, 14 Oct 2026 20:47:54 +0800 Subject: [PATCH 2/2] docs: l10n --- docs/zh-CN/IMPLEMENTATION.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/zh-CN/IMPLEMENTATION.md b/docs/zh-CN/IMPLEMENTATION.md index 7ded7ad..81a8854 100644 --- a/docs/zh-CN/IMPLEMENTATION.md +++ b/docs/zh-CN/IMPLEMENTATION.md @@ -1,4 +1,4 @@ - + # 12pit 实现 @@ -53,8 +53,14 @@ public void stop() { `InputBlocker` 在 Minecraft 处理按键事件前分派事件。功能在启动时注册 `InputHandler`,停止时注销。`key(...)` 返回 true 表示已处理该事件。`consumesKey(...)` 指定输入阻止结束后不应恢复按住状态的按键。 +输入被锁定时,只有锁的持有者会收到 `blockedKey(key, modifiers)`。使用 `Keybind.matches(binding, key, modifiers)` 按事件记录的修饰键状态匹配绑定。输入设备先将这些事件加入队列,再在 tick 开始时分派。回调在 LWJGL 轮询之外执行。 + 使用 `lock(owner)` 阻止键盘和鼠标输入。平台提供的 `InputDevice` 屏蔽 LWJGL 事件和轮询状态。开始阻止时,清除一次 Minecraft 的按键、移动和平滑视角状态。操作完成或取消时,调用 `unlock(owner, resume)`。每个操作只释放自己的锁。`blockedFor(owner)` 判断是否有其他操作持有锁。所有锁释放后才恢复输入。恢复输入后端前,丢弃缓冲事件。`resume` 为 true 且游戏有焦点时,恢复按住的按键状态。`held(key)` 读取当前 LWJGL 状态,阻止期间返回 false。 +持有输入锁时,使用 `runAction(owner, action)` 执行右键或容器点击。`blocksActions()` 供交互钩子判断是否阻止调用。其他持有者的锁仍会阻止该操作。 + +持有锁时,使用 `releaseCursor()` 允许光标移出游戏窗口。`blocksFocus()` 防止菜单再次锁定光标,直到用户点击窗口内或所有输入锁释放。在菜单打开时点击,焦点会在菜单关闭后恢复。 + ## 配置 `FeatureConfig` 表示功能的设置及其界面元数据。在其子类构造器中定义设置: