From cb75a3cfb183028b3267250ca12bdd76787f51d3 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sat, 11 Jul 2026 20:41:52 -0500 Subject: [PATCH 01/19] update .gitignore --- .gitignore | 2 ++ todo.txt | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 todo.txt diff --git a/.gitignore b/.gitignore index c476faf..de8e0a5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ hs_err_*.log replay_*.log *.hprof *.jfr + +docs/ \ No newline at end of file diff --git a/todo.txt b/todo.txt deleted file mode 100644 index c73e202..0000000 --- a/todo.txt +++ /dev/null @@ -1,3 +0,0 @@ -lunge swap -smart attribute swapping -toggle + item clicking > keybinds \ No newline at end of file From 0026d2559676584d666f613b724a942c82fbcc9c Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sat, 11 Jul 2026 20:47:20 -0500 Subject: [PATCH 02/19] extract subsystems from KeybindsClient into com.example.keybinds.* --- src/main/java/com/example/KeybindsClient.java | 543 +++--------------- src/main/java/com/example/KeybindsConfig.java | 3 +- .../core/condition/PlayerConditions.java | 41 ++ .../keybinds/core/input/EdgeDetector.java | 35 ++ .../keybinds/core/schedule/TickScheduler.java | 44 ++ .../example/keybinds/game/PlayerActions.java | 176 ++++++ 6 files changed, 386 insertions(+), 456 deletions(-) create mode 100644 src/main/java/com/example/keybinds/core/condition/PlayerConditions.java create mode 100644 src/main/java/com/example/keybinds/core/input/EdgeDetector.java create mode 100644 src/main/java/com/example/keybinds/core/schedule/TickScheduler.java create mode 100644 src/main/java/com/example/keybinds/game/PlayerActions.java diff --git a/src/main/java/com/example/KeybindsClient.java b/src/main/java/com/example/KeybindsClient.java index 3f40f9a..8b95669 100644 --- a/src/main/java/com/example/KeybindsClient.java +++ b/src/main/java/com/example/KeybindsClient.java @@ -1,5 +1,9 @@ package com.example; +import com.example.keybinds.core.condition.PlayerConditions; +import com.example.keybinds.core.input.EdgeDetector; +import com.example.keybinds.core.schedule.TickScheduler; +import com.example.keybinds.game.PlayerActions; import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.platform.Window; import net.fabricmc.api.ClientModInitializer; @@ -7,12 +11,9 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.MultiPlayerGameMode; import net.minecraft.client.player.LocalPlayer; -import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket; import net.minecraft.resources.Identifier; import net.minecraft.tags.ItemTags; import net.minecraft.world.InteractionHand; -import net.minecraft.world.InteractionResult; -import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.ContainerInput; import net.minecraft.world.inventory.InventoryMenu; @@ -20,105 +21,50 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.item.enchantment.ItemEnchantments; -import net.minecraft.world.phys.BlockHitResult; -import net.minecraft.world.phys.HitResult; import org.lwjgl.glfw.GLFW; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.Iterator; import java.util.List; -import java.util.Set; /** * Client entrypoint. Drives the player's own inventory and item use through the normal item-use / - * container packets (exactly what happens when you right-click an item, press a hotbar number, or - * press the offhand-swap key). Nothing here is special-cased server-side, so it all works both in - * singleplayer and on unmodified multiplayer servers. + * container packets, so everything works in singleplayer and on unmodified servers. * - *

Unlike a typical mod, none of these keys are registered as vanilla {@link net.minecraft.client.KeyMapping} - * mappings. Instead every key — the user-defined Use Item slots and the two combat helpers — - * is stored in {@link KeybindsConfig} and polled here each tick via - * {@link InputConstants#isKeyDown}. That is what lets the {@code Use Item} list be fully dynamic: - * slots are added, removed, and rebound live from the config screen with no game restart and no - * entries in Options → Controls. All key assignment happens in the mod's own screen - * (opened from Mod Menu). + *

No vanilla {@link net.minecraft.client.KeyMapping} mappings are registered. Every key is stored + * in {@link KeybindsConfig} and polled each tick, which keeps binds fully dynamic: added, removed, + * and rebound live from the config screen with no restart. Legacy feature logic lives here until it + * is re-expressed through the macro engine (see docs/architecture.md, milestone M3); the reusable + * subsystems it sits on have been extracted to {@code com.example.keybinds.*}. */ public class KeybindsClient implements ClientModInitializer { - /** Container indices 0..8 are the hotbar; the main inventory is 9..35. */ - private static final int MAIN_INVENTORY_END = 36; - /** {@code buttonNum} value that makes a {@link ContainerInput#SWAP} target the offhand. */ - private static final int OFFHAND_SWAP_BUTTON = 40; - - /** Sentinel for "no slot" used by the slam and hold state below. */ private static final int NONE = -1; - /** - * Offsets that let mouse buttons and per-bind condition state share {@link #downLastTick}'s - * edge-detection with real GLFW key codes without colliding (key codes are well under these). - */ + /** Offsets that let mouse buttons and per-bind condition state share the key-code edge space. */ private static final int MOUSE_BASE = 1_000_000; private static final int COND_BASE = 2_000_000; - /** - * Second-tick state of the mace stun-slam. {@link #pendingMaceSlot} is the mace hotbar slot to - * strike with on the follow-up tick (or {@link #NONE}); {@link #slamReturnSlot} is the slot to - * optionally switch back to afterwards. - */ private int slamReturnSlot = NONE; private int pendingMaceSlot = NONE; - - /** Index of the {@code itemUses} hold-mode bind currently being held, or {@link #NONE}. */ private int activeHoldIndex = NONE; - /** - * Timed steps still to run, for the visible "vanilla swap" sequence (swap-to, use, swap-back) whose - * spacing is user-configurable in ticks. Ticked every client tick regardless of screen state so an - * in-progress swap always completes and never strands the item off its slot. - */ - private final List pending = new ArrayList<>(); - - /** A step scheduled to run after {@code ticks} more client ticks. */ - private static final class Pending { - int ticks; - final Runnable action; - - Pending(int ticks, Runnable action) { - this.ticks = ticks; - this.action = action; - } - } - - /** Our bound key codes that were held down on the previous polling tick, for edge detection. */ - private final Set downLastTick = new HashSet<>(); - /** - * When true, the next polling tick only records key state without firing. Set whenever we stop - * polling (a screen is open, or there is no player) so a key already held on return to gameplay - * is not mistaken for a fresh press. - */ - private boolean resyncNextTick = true; + private final TickScheduler scheduler = new TickScheduler(); + private final EdgeDetector edges = new EdgeDetector(); @Override public void onInitializeClient() { ClientTickEvents.END_CLIENT_TICK.register(client -> { - // Run any scheduled swap steps first so their timing is independent of whether a screen is - // open; a vanilla-swap sequence started in-world must still finish and put the item back. - runPending(); + // Scheduled steps run regardless of screen state so an in-progress swap always completes. + scheduler.tick(); - // The mace stun-slam spans two ticks: finish any slam started last tick first, so its - // mace hit lands on the tick right after the axe hit. + // The mace stun-slam spans two ticks: finish any slam started last tick first. finishMaceStunSlam(client); - // Only poll while actually in-world with no screen open. The grabbed mouse is the signal - // for genuine first-person play: it releases whenever any GUI opens (so binding a key in - // the config screen can't also trigger a use) or the window loses focus. + // Only poll while actually in-world with no screen open; the grabbed mouse is the signal. if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) { if (activeHoldIndex != NONE) { releaseHold(client); } - downLastTick.clear(); - resyncNextTick = true; + edges.reset(); return; } @@ -128,16 +74,11 @@ public void onInitializeClient() { // --- Key polling --------------------------------------------------------------------------- - /** - * Reads every configured key straight from GLFW and acts on fresh presses. Tap-mode use binds - * fire one right-click per press; hold-mode binds engage vanilla's use key while held so - * multi-tick actions (drawing a bow, loading a crossbow, eating) run to completion. The two combat - * helpers fire on press. A key counts as "just pressed" only on the tick it goes down. - */ private void pollInputs(Minecraft client, KeybindsConfig config) { Window window = client.getWindow(); List uses = config.itemUses; int count = Math.min(uses.size(), KeybindsConfig.MAX_ITEM_USES); + boolean resync = edges.resyncing(); // Continue or end an in-progress hold before considering new presses. if (activeHoldIndex != NONE) { @@ -151,23 +92,19 @@ private void pollInputs(Minecraft client, KeybindsConfig config) { } } - Set downNow = new HashSet<>(); - for (int i = 0; i < count; i++) { KeybindsConfig.ItemUse use = uses.get(i); - // Each enabled trigger is always polled (so its held state is recorded for next tick's edge - // detection) but only fires when it freshly transitions to down and we are not resyncing. + // Each enabled trigger is always polled (so its held state feeds next tick's edge + // detection) but only fires on a fresh down-transition outside a resync tick. boolean fire = false; String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; - // 1. Primary trigger: EITHER the key OR a click — never both. Hold mode is key-only: a held - // key drives a multi-tick use; a click has no sustained input, so it does a single use. if (mode.equals(KeybindsConfig.TRIGGER_KEY)) { if (KeybindsConfig.isBound(use.key)) { - boolean pressed = isJustPressed(window, use.key, downNow); - if (!resyncNextTick && pressed) { + boolean pressed = keyJustPressed(window, use.key); + if (!resync && pressed) { if (use.hold) { if (activeHoldIndex == NONE) { Item item = KeybindsConfig.resolveItem(use.item); @@ -183,32 +120,26 @@ private void pollInputs(Minecraft client, KeybindsConfig config) { } else { boolean rightClick = mode.equals(KeybindsConfig.TRIGGER_RIGHT_CLICK); int button = rightClick ? GLFW.GLFW_MOUSE_BUTTON_RIGHT : GLFW.GLFW_MOUSE_BUTTON_LEFT; - boolean pressed = isMouseJustPressed(window, button, downNow); - if (!resyncNextTick && pressed && holdingClickItem(client, use)) { + boolean pressed = mouseJustPressed(window, button); + if (!resync && pressed && holdingClickItem(client, use)) { // Right-click is vanilla's own use key: if the bind's item is already in the main - // hand, vanilla just used it on this very click, so firing again would double it - // (two pearls per click, etc.). Left-click (attack/mine) never uses an item, so it - // always needs our use. + // hand, vanilla just used it on this very click, so firing again would double it. if (!(rightClick && usedItemInMainHand(client, use))) { fire = true; } } } - // 2. Environment-condition triggers, fired on the onset of any listed condition (additive). + // Environment-condition triggers, fired on the onset of any listed condition (additive). if (use.conditions != null && !use.conditions.isEmpty()) { boolean active = false; for (String id : use.conditions) { - if (conditionActive(client, id)) { + if (PlayerConditions.active(id, client.player)) { active = true; break; } } - int code = COND_BASE + i; - if (active) { - downNow.add(code); - } - if (!resyncNextTick && active && !downLastTick.contains(code)) { + if (edges.justPressed(COND_BASE + i, active) && !resync) { fire = true; } } @@ -219,73 +150,51 @@ private void pollInputs(Minecraft client, KeybindsConfig config) { } if (KeybindsConfig.isBound(config.shieldTotemKey) - && isJustPressed(window, config.shieldTotemKey, downNow) && !resyncNextTick) { + && keyJustPressed(window, config.shieldTotemKey) && !resync) { if (config.shieldTotemVanilla) { - pending.add(new Pending(config.combatSwapToDelay, () -> trySwapShieldTotem(client))); + scheduler.schedule(config.combatSwapToDelay, () -> trySwapShieldTotem(client)); } else { trySwapShieldTotem(client); } } if (KeybindsConfig.isBound(config.maceStunSlamKey) - && isJustPressed(window, config.maceStunSlamKey, downNow) && !resyncNextTick) { + && keyJustPressed(window, config.maceStunSlamKey) && !resync) { startMaceStunSlam(client); } - // Lunge swap: fires from a key and/or a left/right-click, gated by the held-item filter. Every - // enabled trigger is polled unconditionally (so its edge state is recorded) but only the ones - // that just went down set the flag, and only outside a resync with the filter passing act. + // Lunge swap: every enabled trigger is polled unconditionally so its edge state is recorded. boolean lungeFire = false; - if (KeybindsConfig.isBound(config.lungeSwapKey) - && isJustPressed(window, config.lungeSwapKey, downNow)) { + if (KeybindsConfig.isBound(config.lungeSwapKey) && keyJustPressed(window, config.lungeSwapKey)) { lungeFire = true; } - if (config.lungeSwapLeftClick - && isMouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_LEFT, downNow)) { + if (config.lungeSwapLeftClick && mouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_LEFT)) { lungeFire = true; } - if (config.lungeSwapRightClick - && isMouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_RIGHT, downNow)) { + if (config.lungeSwapRightClick && mouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_RIGHT)) { lungeFire = true; } - if (lungeFire && !resyncNextTick && holdingLungeItem(client, config)) { + if (lungeFire && !resync && holdingLungeItem(client, config)) { startLungeSwap(client, config); } - downLastTick.clear(); - downLastTick.addAll(downNow); - resyncNextTick = false; + edges.commit(); } - /** - * Whether {@code key} transitioned to down this tick. Records the key in {@code downNow} when it is - * currently held so {@link #downLastTick} can be rebuilt for next tick's edge detection. - */ - private boolean isJustPressed(Window window, int key, Set downNow) { - boolean down = InputConstants.isKeyDown(window, key); - if (down) { - downNow.add(key); - } - return down && !downLastTick.contains(key); + private boolean keyJustPressed(Window window, int key) { + return edges.justPressed(key, InputConstants.isKeyDown(window, key)); } - /** Whether {@code button} (a GLFW mouse button) transitioned to down this tick, tracked like a key. */ - private boolean isMouseJustPressed(Window window, int button, Set downNow) { + private boolean mouseJustPressed(Window window, int button) { boolean down = GLFW.glfwGetMouseButton(window.handle(), button) == GLFW.GLFW_PRESS; - int code = MOUSE_BASE + button; - if (down) { - downNow.add(code); - } - return down && !downLastTick.contains(code); + return edges.justPressed(MOUSE_BASE + button, down); } - /** Whether the bind's target item is in the main hand — i.e. a right-click already used it via vanilla. */ private static boolean usedItemInMainHand(Minecraft client, KeybindsConfig.ItemUse use) { LocalPlayer player = client.player; Item item = KeybindsConfig.resolveItem(use.item); return player != null && item != null && player.getMainHandItem().is(item); } - /** Whether the player's main hand holds one of the bind's click items (an empty list means any item). */ private static boolean holdingClickItem(Minecraft client, KeybindsConfig.ItemUse use) { LocalPlayer player = client.player; if (player == null) { @@ -304,52 +213,8 @@ private static boolean holdingClickItem(Minecraft client, KeybindsConfig.ItemUse return false; } - /** Evaluate one environment-condition id against the local player. Thresholds are fixed presets. */ - private static boolean conditionActive(Minecraft client, String id) { - LocalPlayer player = client.player; - if (player == null) { - return false; - } - return switch (id) { - case "low_health" -> player.getHealth() <= 6.0f; // 3 hearts or less - case "on_fire" -> player.isOnFire(); - case "in_lava" -> player.isInLava(); - case "falling" -> player.fallDistance > 3.0f; // past roughly safe fall distance - case "in_water" -> player.isInWater(); - case "submerged" -> player.isUnderWater(); - case "hurt" -> player.hurtTime > 0; - case "sneaking" -> player.isShiftKeyDown(); - case "sprinting" -> player.isSprinting(); - default -> false; - }; - } - - // --- Scheduled steps ----------------------------------------------------------------------- - - /** Run and drop every scheduled step whose countdown has elapsed; decrement the rest. */ - private void runPending() { - if (pending.isEmpty()) { - return; - } - Iterator it = pending.iterator(); - while (it.hasNext()) { - Pending step = it.next(); - if (step.ticks <= 0) { - step.action.run(); - it.remove(); - } else { - step.ticks--; - } - } - } - // --- Use-item binds ------------------------------------------------------------------------ - /** - * Fire a bind's use once, honoring its hotbar gate and vanilla-swap preference. The item is located - * (hands / hotbar / buried in the main inventory); the gate can veto the fire based on that location; - * a buried item with {@code vanillaSwap} runs the visible timed swap, otherwise the instant path. - */ private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) { LocalPlayer player = client.player; MultiPlayerGameMode gameMode = client.gameMode; @@ -364,7 +229,7 @@ private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) { Inventory inventory = player.getInventory(); boolean inMainHand = player.getMainHandItem().is(item); boolean inHands = inMainHand || player.getOffhandItem().is(item); - int hotbarSlot = findHotbarSlot(inventory, item); + int hotbarSlot = PlayerActions.findHotbarSlot(inventory, item); boolean onHotbar = inHands || hotbarSlot >= 0; String gate = use.hotbarGate == null ? KeybindsConfig.GATE_ANY : use.hotbarGate; @@ -375,10 +240,9 @@ private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) { return; } - // A buried item can be used the fast (silent) way or the visible legit-looking way. int buriedSlot = NONE; if (!onHotbar && player.containerMenu == player.inventoryMenu) { - for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) { + for (int slot = Inventory.SELECTION_SIZE; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { if (inventory.getItem(slot).is(item)) { buriedSlot = slot; break; @@ -386,26 +250,15 @@ private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) { } } - // Vanilla swap makes the whole thing look manual and tick-spaced instead of instant/silent: - // - buried in the inventory: bring it to the held slot, right-click, swap it back; - // - sitting on the hotbar (but not already in hand): visibly switch to that slot, right-click, - // switch back — the same slot flick useItemInstant does, but spread over ticks so it shows. - // An item already in a hand needs no switch, so it just uses instantly. if (use.vanillaSwap && buriedSlot != NONE) { scheduleVanillaSwap(client, use, buriedSlot); } else if (use.vanillaSwap && !inMainHand && hotbarSlot >= 0) { scheduleVanillaHotbarUse(client, use, hotbarSlot); } else { - useItemInstant(client, item); + PlayerActions.useItemInstant(client, item); } } - /** - * Schedule a visible hotbar-slot switch-use-switch for an item already on the hotbar: move the - * selection to its slot, right-click after the use delay, then switch back to the original slot — - * with the user's tick gaps between each step so it reads as a real slot change rather than an - * instant same-tick flick. - */ private void scheduleVanillaHotbarUse(Minecraft client, KeybindsConfig.ItemUse use, int hotbarSlot) { LocalPlayer player = client.player; if (player == null) { @@ -417,25 +270,11 @@ private void scheduleVanillaHotbarUse(Minecraft client, KeybindsConfig.ItemUse u int atUse = atSwapTo + use.useDelay; int atSwapBack = atUse + use.swapBackDelay; - pending.add(new Pending(atSwapTo, () -> selectHotbarSlotIfPresent(client, hotbarSlot))); - pending.add(new Pending(atUse, () -> doInstantUse(client, InteractionHand.MAIN_HAND))); - pending.add(new Pending(atSwapBack, () -> selectHotbarSlotIfPresent(client, previousSlot))); + scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, hotbarSlot)); + scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); + scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); } - /** Switch the held hotbar slot from a scheduled step, guarding against the player having gone away. */ - private static void selectHotbarSlotIfPresent(Minecraft client, int slot) { - LocalPlayer player = client.player; - if (player == null || slot < 0 || slot >= Inventory.SELECTION_SIZE) { - return; - } - selectHotbarSlot(client, player.getInventory(), slot); - } - - /** - * Schedule the visible three-step swap for an inventory-buried item: bring it to the held hotbar - * slot, right-click, then swap it back — with the user's configured tick gaps between each step so - * it reads as a genuine manual swap-use-swap rather than an instant silent container edit. - */ private void scheduleVanillaSwap(Minecraft client, KeybindsConfig.ItemUse use, int buriedSlot) { LocalPlayer player = client.player; if (player == null || player.containerMenu != player.inventoryMenu) { @@ -449,99 +288,11 @@ private void scheduleVanillaSwap(Minecraft client, KeybindsConfig.ItemUse use, i int atUse = atSwapTo + use.useDelay; int atSwapBack = atUse + use.swapBackDelay; - pending.add(new Pending(atSwapTo, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot))); - pending.add(new Pending(atUse, () -> doInstantUse(client, InteractionHand.MAIN_HAND))); - pending.add(new Pending(atSwapBack, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot))); - } - - /** SWAP the main-inventory slot with the currently held hotbar slot, if the inventory menu is still open. */ - private static void swapBuriedWithHeld(Minecraft client, int containerId, int buriedSlot, int hotbarSlot) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null || player.containerMenu != player.inventoryMenu) { - return; - } - gameMode.handleContainerInput(containerId, buriedSlot, hotbarSlot, ContainerInput.SWAP, player); + scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); + scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); + scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); } - /** - * Single right-click of {@code item} wherever it can be found (a hand, then the hotbar, then the - * main inventory). Mirrors vanilla: if a block is targeted the item is used against it (so blocks - * place and buckets/flint&steel work), otherwise it is used in the air (throwables, eggs, - * firing a loaded crossbow, ...). - */ - private static void useItemInstant(Minecraft client, Item item) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - - // 1. Already in a hand. - for (InteractionHand hand : InteractionHand.values()) { - if (player.getItemInHand(hand).is(item)) { - doInstantUse(client, hand); - return; - } - } - - Inventory inventory = player.getInventory(); - - // 2. On the hotbar: briefly select that slot, use it, switch back. - for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { - if (inventory.getItem(slot).is(item)) { - int previousSlot = inventory.getSelectedSlot(); - selectHotbarSlot(client, inventory, slot); - doInstantUse(client, InteractionHand.MAIN_HAND); - selectHotbarSlot(client, inventory, previousSlot); - return; - } - } - - // 3. Buried in the main inventory. Only reachable while no other container is open, since - // we drive the player's own inventory menu. Swap onto the held slot, use, swap back. - if (player.containerMenu == player.inventoryMenu) { - int hotbarSlot = inventory.getSelectedSlot(); - int containerId = player.inventoryMenu.containerId; - for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) { - if (inventory.getItem(slot).is(item)) { - gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player); - doInstantUse(client, InteractionHand.MAIN_HAND); - gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player); - return; - } - } - } - } - - /** One right-click with the given hand, using a targeted block if there is one, else the air. */ - private static void doInstantUse(Minecraft client, InteractionHand hand) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - - HitResult hit = client.hitResult; - if (hit instanceof BlockHitResult blockHit && hit.getType() == HitResult.Type.BLOCK) { - InteractionResult result = gameMode.useItemOn(player, hand, blockHit); - if (result.consumesAction()) { - player.swing(hand); - return; - } - } - - InteractionResult result = gameMode.useItem(player, hand); - if (result.consumesAction()) { - player.swing(hand); - } - } - - /** - * Begin a hold-mode use: select the item on the hotbar (or keep the current slot if it is - * already held) and engage vanilla's use key, which then drives the whole multi-tick action. - * Hold mode only works from the hotbar / main hand, since holding an item requires it selected. - */ private void startHold(Minecraft client, int index, Item item) { LocalPlayer player = client.player; if (player == null) { @@ -549,7 +300,7 @@ private void startHold(Minecraft client, int index, Item item) { } Inventory inventory = player.getInventory(); - int slot = findHotbarSlot(inventory, item); + int slot = PlayerActions.findHotbarSlot(inventory, item); if (slot < 0) { if (!player.getMainHandItem().is(item)) { return; @@ -557,15 +308,14 @@ private void startHold(Minecraft client, int index, Item item) { slot = inventory.getSelectedSlot(); } - selectHotbarSlot(client, inventory, slot); + PlayerActions.selectHotbarSlot(client, inventory, slot); activeHoldIndex = index; client.options.keyUse.setDown(true); } /** - * End a hold-mode use: release vanilla's use key so the action finishes (a drawn bow fires here). - * The selected slot is intentionally left as-is — switching hotbar slots cancels an in-progress - * use, which would eat the shot — so the player simply keeps holding the item. + * End a hold-mode use so the action finishes (a drawn bow fires here). The selected slot is + * intentionally left as-is — switching hotbar slots cancels an in-progress use. */ private void releaseHold(Minecraft client) { activeHoldIndex = NONE; @@ -576,17 +326,6 @@ private void releaseHold(Minecraft client) { // --- Shield / totem swap ------------------------------------------------------------------- - /** - * Toggles the offhand between a shield and a totem of undying: - *

- * Everything is driven through the player's own inventory menu, so it only runs while no other - * container is open — but it does not require the inventory screen to be visually open. - */ private static void trySwapShieldTotem(Minecraft client) { LocalPlayer player = client.player; MultiPlayerGameMode gameMode = client.gameMode; @@ -602,28 +341,23 @@ private static void trySwapShieldTotem(Minecraft client) { ItemStack offhand = player.getItemInHand(InteractionHand.OFF_HAND); if (offhand.is(Items.SHIELD)) { - int totemSlot = findItem(inventory, Items.TOTEM_OF_UNDYING); + int totemSlot = PlayerActions.findItem(inventory, Items.TOTEM_OF_UNDYING); if (totemSlot >= 0) { - swapWithOffhand(gameMode, containerId, player, totemSlot); + PlayerActions.swapWithOffhand(gameMode, containerId, player, totemSlot); } else { gameMode.handleContainerInput( containerId, InventoryMenu.SHIELD_SLOT, 0, ContainerInput.QUICK_MOVE, player); } } else { - int shieldSlot = findItem(inventory, Items.SHIELD); + int shieldSlot = PlayerActions.findItem(inventory, Items.SHIELD); if (shieldSlot >= 0) { - swapWithOffhand(gameMode, containerId, player, shieldSlot); + PlayerActions.swapWithOffhand(gameMode, containerId, player, shieldSlot); } } } // --- Mace stun-slam ------------------------------------------------------------------------ - /** - * First tick of the mace stun-slam: requires both a mace and an axe on the hotbar, then switches - * to the axe and attacks in the same tick. The mace follow-up is left to - * {@link #finishMaceStunSlam(Minecraft)} on the very next tick. - */ private void startMaceStunSlam(Minecraft client) { LocalPlayer player = client.player; MultiPlayerGameMode gameMode = client.gameMode; @@ -632,8 +366,8 @@ private void startMaceStunSlam(Minecraft client) { } Inventory inventory = player.getInventory(); - int axeSlot = findHotbarSlot(inventory, ItemTags.AXES); - int maceSlot = findHotbarSlot(inventory, Items.MACE); + int axeSlot = PlayerActions.findHotbarSlot(inventory, ItemTags.AXES); + int maceSlot = PlayerActions.findHotbarSlot(inventory, Items.MACE); if (axeSlot < 0 || maceSlot < 0) { return; } @@ -641,22 +375,20 @@ private void startMaceStunSlam(Minecraft client) { int returnSlot = inventory.getSelectedSlot(); KeybindsConfig config = KeybindsConfig.get(); - // Vanilla mode spreads the axe hit, the mace hit, and the optional slot restore over the shared - // combat tick delays so it reads as a manual two-swap combo rather than an instant same-tick flick. if (config.maceStunSlamVanilla) { int atAxe = config.combatSwapToDelay; int atMace = atAxe + config.combatUseDelay; int atBack = atMace + config.combatSwapBackDelay; - pending.add(new Pending(atAxe, () -> { - selectHotbarSlotIfPresent(client, axeSlot); - attackCrosshairTarget(client); - })); - pending.add(new Pending(atMace, () -> { - selectHotbarSlotIfPresent(client, maceSlot); - attackCrosshairTarget(client); - })); + scheduler.schedule(atAxe, () -> { + PlayerActions.selectHotbarSlotIfValid(client, axeSlot); + PlayerActions.attackCrosshairTarget(client); + }); + scheduler.schedule(atMace, () -> { + PlayerActions.selectHotbarSlotIfValid(client, maceSlot); + PlayerActions.attackCrosshairTarget(client); + }); if (config.returnToStartSlotAfterSlam) { - pending.add(new Pending(atBack, () -> selectHotbarSlotIfPresent(client, returnSlot))); + scheduler.schedule(atBack, () -> PlayerActions.selectHotbarSlotIfValid(client, returnSlot)); } return; } @@ -665,14 +397,10 @@ private void startMaceStunSlam(Minecraft client) { slamReturnSlot = returnSlot; pendingMaceSlot = maceSlot; - selectHotbarSlot(client, inventory, axeSlot); - attackCrosshairTarget(client); + PlayerActions.selectHotbarSlot(client, inventory, axeSlot); + PlayerActions.attackCrosshairTarget(client); } - /** - * Second tick of the mace stun-slam (the tick after {@link #startMaceStunSlam(Minecraft)}): - * switch to the mace and attack again. Optionally restores the originally selected slot. - */ private void finishMaceStunSlam(Minecraft client) { if (pendingMaceSlot == NONE) { return; @@ -688,31 +416,16 @@ private void finishMaceStunSlam(Minecraft client) { } Inventory inventory = player.getInventory(); - selectHotbarSlot(client, inventory, maceSlot); - attackCrosshairTarget(client); + PlayerActions.selectHotbarSlot(client, inventory, maceSlot); + PlayerActions.attackCrosshairTarget(client); if (KeybindsConfig.get().returnToStartSlotAfterSlam && returnSlot != NONE) { - selectHotbarSlot(client, inventory, returnSlot); - } - } - - /** Attack whatever entity is currently under the crosshair, exactly like a left-click would. */ - private static void attackCrosshairTarget(Minecraft client) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - Entity target = client.crosshairPickEntity; - if (target != null) { - gameMode.attack(player, target); - player.swing(InteractionHand.MAIN_HAND); + PlayerActions.selectHotbarSlot(client, inventory, returnSlot); } } // --- Lunge swap ---------------------------------------------------------------------------- - /** Whether the main hand passes the lunge held-item filter (empty list = any; {@code minecraft:air} = empty hand). */ private static boolean holdingLungeItem(Minecraft client, KeybindsConfig config) { LocalPlayer player = client.player; if (player == null) { @@ -732,12 +445,6 @@ private static boolean holdingLungeItem(Minecraft client, KeybindsConfig config) return false; } - /** - * Swap to the best spear on hand — the carried spear with the highest {@code lungeEnchant} level — - * left-click with it, then return to the original slot. Prefers a spear already on the hotbar; falls - * back to one buried in the main inventory. Vanilla mode spreads the switch-attack-switch over the - * shared combat tick delays; otherwise it is an instant same-tick flick. - */ private void startLungeSwap(Minecraft client, KeybindsConfig config) { LocalPlayer player = client.player; if (player == null || client.gameMode == null) { @@ -745,10 +452,10 @@ private void startLungeSwap(Minecraft client, KeybindsConfig config) { } Inventory inventory = player.getInventory(); - // Highest-lunge spear across the whole inventory; ties keep the lower slot (hotbar before pack). + // Highest-lunge spear across the whole inventory; ties keep the lower slot (hotbar first). int bestSlot = NONE; int bestLevel = Integer.MIN_VALUE; - for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) { + for (int slot = 0; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { ItemStack stack = inventory.getItem(slot); if (!isSpear(config, stack)) { continue; @@ -770,7 +477,6 @@ private void startLungeSwap(Minecraft client, KeybindsConfig config) { } } - /** Switch to a hotbar spear, attack, switch back — instantly, or tick-spaced in vanilla mode. */ private void lungeFromHotbar(Minecraft client, KeybindsConfig config, int spearSlot) { LocalPlayer player = client.player; if (player == null) { @@ -780,21 +486,20 @@ private void lungeFromHotbar(Minecraft client, KeybindsConfig config, int spearS if (!config.lungeSwapVanilla) { Inventory inventory = player.getInventory(); - selectHotbarSlot(client, inventory, spearSlot); - lungeAttack(client); - selectHotbarSlot(client, inventory, previousSlot); + PlayerActions.selectHotbarSlot(client, inventory, spearSlot); + PlayerActions.attackCrosshairOrSwing(client); + PlayerActions.selectHotbarSlot(client, inventory, previousSlot); return; } int atSwapTo = config.combatSwapToDelay; int atUse = atSwapTo + config.combatUseDelay; int atSwapBack = atUse + config.combatSwapBackDelay; - pending.add(new Pending(atSwapTo, () -> selectHotbarSlotIfPresent(client, spearSlot))); - pending.add(new Pending(atUse, () -> lungeAttack(client))); - pending.add(new Pending(atSwapBack, () -> selectHotbarSlotIfPresent(client, previousSlot))); + scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, spearSlot)); + scheduler.schedule(atUse, () -> PlayerActions.attackCrosshairOrSwing(client)); + scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); } - /** Bring a buried spear to the held slot, attack, swap it back — instantly, or tick-spaced in vanilla mode. */ private void lungeFromBuried(Minecraft client, KeybindsConfig config, int buriedSlot) { LocalPlayer player = client.player; if (player == null || player.containerMenu != player.inventoryMenu) { @@ -805,35 +510,20 @@ private void lungeFromBuried(Minecraft client, KeybindsConfig config, int buried int containerId = player.inventoryMenu.containerId; if (!config.lungeSwapVanilla) { - swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); - lungeAttack(client); - swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); + PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); + PlayerActions.attackCrosshairOrSwing(client); + PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); return; } int atSwapTo = config.combatSwapToDelay; int atUse = atSwapTo + config.combatUseDelay; int atSwapBack = atUse + config.combatSwapBackDelay; - pending.add(new Pending(atSwapTo, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot))); - pending.add(new Pending(atUse, () -> lungeAttack(client))); - pending.add(new Pending(atSwapBack, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot))); + scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); + scheduler.schedule(atUse, () -> PlayerActions.attackCrosshairOrSwing(client)); + scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); } - /** A left-click with the main hand: attack whatever is under the crosshair (if any) and always swing. */ - private static void lungeAttack(Minecraft client) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - Entity target = client.crosshairPickEntity; - if (target != null) { - gameMode.attack(player, target); - } - player.swing(InteractionHand.MAIN_HAND); - } - - /** Whether {@code stack} is a configured spear (or, if no ids are configured, any lunge-enchanted item). */ private static boolean isSpear(KeybindsConfig config, ItemStack stack) { if (stack.isEmpty()) { return false; @@ -851,7 +541,6 @@ private static boolean isSpear(KeybindsConfig config, ItemStack stack) { return false; } - /** Level of the configured lunge enchantment on {@code stack}, or 0 if absent. */ private static int lungeLevel(KeybindsConfig config, ItemStack stack) { if (stack.isEmpty() || config.lungeEnchant == null || config.lungeEnchant.isBlank()) { return 0; @@ -868,58 +557,4 @@ private static int lungeLevel(KeybindsConfig config, ItemStack stack) { } return 0; } - - // --- Inventory helpers --------------------------------------------------------------------- - - /** First hotbar slot (0..8) holding {@code item}, or -1. */ - private static int findHotbarSlot(Inventory inventory, Item item) { - for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { - if (inventory.getItem(slot).is(item)) { - return slot; - } - } - return -1; - } - - /** First hotbar slot (0..8) holding an item in {@code tag}, or -1. */ - private static int findHotbarSlot(Inventory inventory, net.minecraft.tags.TagKey tag) { - for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { - if (inventory.getItem(slot).is(tag)) { - return slot; - } - } - return -1; - } - - /** First inventory index (hotbar 0..8, then main 9..35) holding {@code item}, or -1. */ - private static int findItem(Inventory inventory, Item item) { - for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) { - if (inventory.getItem(slot).is(item)) { - return slot; - } - } - return -1; - } - - /** Swap the stack at the given inventory container index with the offhand slot. */ - private static void swapWithOffhand(MultiPlayerGameMode gameMode, int containerId, - LocalPlayer player, int containerIndex) { - gameMode.handleContainerInput( - containerId, menuSlotOf(containerIndex), OFFHAND_SWAP_BUTTON, ContainerInput.SWAP, player); - } - - /** Map a player-inventory container index onto its {@link InventoryMenu} slot id. */ - private static int menuSlotOf(int containerIndex) { - return containerIndex < Inventory.SELECTION_SIZE - ? InventoryMenu.USE_ROW_SLOT_START + containerIndex - : containerIndex; - } - - /** Switch the held hotbar slot and tell the server, mirroring vanilla scroll/number-key selection. */ - private static void selectHotbarSlot(Minecraft client, Inventory inventory, int slot) { - inventory.setSelectedSlot(slot); - if (client.getConnection() != null) { - client.getConnection().send(new ServerboundSetCarriedItemPacket(slot)); - } - } } diff --git a/src/main/java/com/example/KeybindsConfig.java b/src/main/java/com/example/KeybindsConfig.java index c3ea89d..8fb9ff9 100644 --- a/src/main/java/com/example/KeybindsConfig.java +++ b/src/main/java/com/example/KeybindsConfig.java @@ -63,8 +63,7 @@ public class KeybindsConfig { * tick; a bind with any of these listed fires when any of them flips from false to true * (edge-triggered, so it fires once per onset, not every tick while true). Thresholds are pre-set. */ - public static final List CONDITIONS = List.of( - "low_health", "on_fire", "in_lava", "falling", "in_water", "submerged", "hurt", "sneaking", "sprinting"); + public static final List CONDITIONS = com.example.keybinds.core.condition.PlayerConditions.ids(); /** * One user-defined bind. At its core it names an item to right-click ({@link #item}) and a tap/hold diff --git a/src/main/java/com/example/keybinds/core/condition/PlayerConditions.java b/src/main/java/com/example/keybinds/core/condition/PlayerConditions.java new file mode 100644 index 0000000..4835877 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/condition/PlayerConditions.java @@ -0,0 +1,41 @@ +package com.example.keybinds.core.condition; + +import net.minecraft.client.player.LocalPlayer; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public final class PlayerConditions { + + private static final Map> CONDITIONS = new LinkedHashMap<>(); + + static { + register("low_health", p -> p.getHealth() <= 6.0f); + register("on_fire", LocalPlayer::isOnFire); + register("in_lava", LocalPlayer::isInLava); + register("falling", p -> p.fallDistance > 3.0f); + register("in_water", LocalPlayer::isInWater); + register("submerged", LocalPlayer::isUnderWater); + register("hurt", p -> p.hurtTime > 0); + register("sneaking", LocalPlayer::isShiftKeyDown); + register("sprinting", LocalPlayer::isSprinting); + } + + private PlayerConditions() { + } + + public static void register(String id, Predicate test) { + CONDITIONS.put(id, test); + } + + public static boolean active(String id, LocalPlayer player) { + Predicate test = CONDITIONS.get(id); + return test != null && player != null && test.test(player); + } + + public static List ids() { + return List.copyOf(CONDITIONS.keySet()); + } +} diff --git a/src/main/java/com/example/keybinds/core/input/EdgeDetector.java b/src/main/java/com/example/keybinds/core/input/EdgeDetector.java new file mode 100644 index 0000000..bb5dc44 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/input/EdgeDetector.java @@ -0,0 +1,35 @@ +package com.example.keybinds.core.input; + +import java.util.HashSet; +import java.util.Set; + +public final class EdgeDetector { + + private final Set downLastTick = new HashSet<>(); + private final Set downNow = new HashSet<>(); + private boolean resync = true; + + public boolean justPressed(int code, boolean down) { + if (down) { + downNow.add(code); + } + return down && !downLastTick.contains(code); + } + + public boolean resyncing() { + return resync; + } + + public void commit() { + downLastTick.clear(); + downLastTick.addAll(downNow); + downNow.clear(); + resync = false; + } + + public void reset() { + downLastTick.clear(); + downNow.clear(); + resync = true; + } +} diff --git a/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java b/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java new file mode 100644 index 0000000..be1c2a4 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java @@ -0,0 +1,44 @@ +package com.example.keybinds.core.schedule; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +public final class TickScheduler { + + private final List tasks = new ArrayList<>(); + + private static final class Task { + int ticks; + final Runnable action; + + Task(int ticks, Runnable action) { + this.ticks = ticks; + this.action = action; + } + } + + public void schedule(int delayTicks, Runnable action) { + tasks.add(new Task(delayTicks, action)); + } + + public void tick() { + if (tasks.isEmpty()) { + return; + } + Iterator it = tasks.iterator(); + while (it.hasNext()) { + Task task = it.next(); + if (task.ticks <= 0) { + task.action.run(); + it.remove(); + } else { + task.ticks--; + } + } + } + + public boolean isIdle() { + return tasks.isEmpty(); + } +} diff --git a/src/main/java/com/example/keybinds/game/PlayerActions.java b/src/main/java/com/example/keybinds/game/PlayerActions.java new file mode 100644 index 0000000..369b620 --- /dev/null +++ b/src/main/java/com/example/keybinds/game/PlayerActions.java @@ -0,0 +1,176 @@ +package com.example.keybinds.game; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket; +import net.minecraft.tags.TagKey; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.item.Item; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; + +public final class PlayerActions { + + public static final int MAIN_INVENTORY_END = 36; + public static final int OFFHAND_SWAP_BUTTON = 40; + + private PlayerActions() { + } + + public static void selectHotbarSlot(Minecraft client, Inventory inventory, int slot) { + inventory.setSelectedSlot(slot); + if (client.getConnection() != null) { + client.getConnection().send(new ServerboundSetCarriedItemPacket(slot)); + } + } + + public static void selectHotbarSlotIfValid(Minecraft client, int slot) { + LocalPlayer player = client.player; + if (player == null || slot < 0 || slot >= Inventory.SELECTION_SIZE) { + return; + } + selectHotbarSlot(client, player.getInventory(), slot); + } + + public static void useHand(Minecraft client, InteractionHand hand) { + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null) { + return; + } + + HitResult hit = client.hitResult; + if (hit instanceof BlockHitResult blockHit && hit.getType() == HitResult.Type.BLOCK) { + InteractionResult result = gameMode.useItemOn(player, hand, blockHit); + if (result.consumesAction()) { + player.swing(hand); + return; + } + } + + InteractionResult result = gameMode.useItem(player, hand); + if (result.consumesAction()) { + player.swing(hand); + } + } + + public static void useItemInstant(Minecraft client, Item item) { + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null) { + return; + } + + for (InteractionHand hand : InteractionHand.values()) { + if (player.getItemInHand(hand).is(item)) { + useHand(client, hand); + return; + } + } + + Inventory inventory = player.getInventory(); + + for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { + if (inventory.getItem(slot).is(item)) { + int previousSlot = inventory.getSelectedSlot(); + selectHotbarSlot(client, inventory, slot); + useHand(client, InteractionHand.MAIN_HAND); + selectHotbarSlot(client, inventory, previousSlot); + return; + } + } + + if (player.containerMenu == player.inventoryMenu) { + int hotbarSlot = inventory.getSelectedSlot(); + int containerId = player.inventoryMenu.containerId; + for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) { + if (inventory.getItem(slot).is(item)) { + gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player); + useHand(client, InteractionHand.MAIN_HAND); + gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player); + return; + } + } + } + } + + public static void attackCrosshairTarget(Minecraft client) { + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null) { + return; + } + Entity target = client.crosshairPickEntity; + if (target != null) { + gameMode.attack(player, target); + player.swing(InteractionHand.MAIN_HAND); + } + } + + public static void attackCrosshairOrSwing(Minecraft client) { + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null) { + return; + } + Entity target = client.crosshairPickEntity; + if (target != null) { + gameMode.attack(player, target); + } + player.swing(InteractionHand.MAIN_HAND); + } + + public static void swapBuriedWithHeld(Minecraft client, int containerId, int buriedSlot, int hotbarSlot) { + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null || player.containerMenu != player.inventoryMenu) { + return; + } + gameMode.handleContainerInput(containerId, buriedSlot, hotbarSlot, ContainerInput.SWAP, player); + } + + public static void swapWithOffhand(MultiPlayerGameMode gameMode, int containerId, + LocalPlayer player, int containerIndex) { + gameMode.handleContainerInput( + containerId, menuSlotOf(containerIndex), OFFHAND_SWAP_BUTTON, ContainerInput.SWAP, player); + } + + public static int menuSlotOf(int containerIndex) { + return containerIndex < Inventory.SELECTION_SIZE + ? InventoryMenu.USE_ROW_SLOT_START + containerIndex + : containerIndex; + } + + public static int findHotbarSlot(Inventory inventory, Item item) { + for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { + if (inventory.getItem(slot).is(item)) { + return slot; + } + } + return -1; + } + + public static int findHotbarSlot(Inventory inventory, TagKey tag) { + for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { + if (inventory.getItem(slot).is(tag)) { + return slot; + } + } + return -1; + } + + public static int findItem(Inventory inventory, Item item) { + for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) { + if (inventory.getItem(slot).is(item)) { + return slot; + } + } + return -1; + } +} From 2ea91d8fd2b1d8597ebc65fa9071b3be00021778 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sat, 11 Jul 2026 20:57:47 -0500 Subject: [PATCH 03/19] add macro data model with versioned macros.json persistence --- src/main/java/com/example/KeybindsClient.java | 8 ++ .../example/keybinds/core/action/Action.java | 10 ++ .../keybinds/core/action/ActionRegistry.java | 49 +++++++++ .../keybinds/core/action/ActionType.java | 8 ++ .../keybinds/core/action/ActionTypes.java | 24 +++++ .../keybinds/core/action/AttackAction.java | 15 +++ .../core/action/SelectSlotAction.java | 16 +++ .../keybinds/core/action/UseAction.java | 15 +++ .../keybinds/core/action/WaitAction.java | 16 +++ .../example/keybinds/core/macro/Macro.java | 61 ++++++++++++ .../keybinds/core/macro/MacroRegistry.java | 43 ++++++++ .../keybinds/core/trigger/AllOfTrigger.java | 23 +++++ .../keybinds/core/trigger/AnyOfTrigger.java | 23 +++++ .../core/trigger/ConditionTrigger.java | 16 +++ .../keybinds/core/trigger/KeyTrigger.java | 16 +++ .../core/trigger/MouseButtonTrigger.java | 16 +++ .../keybinds/core/trigger/NotTrigger.java | 16 +++ .../keybinds/core/trigger/Trigger.java | 10 ++ .../core/trigger/TriggerRegistry.java | 49 +++++++++ .../keybinds/core/trigger/TriggerType.java | 8 ++ .../keybinds/core/trigger/TriggerTypes.java | 54 ++++++++++ .../keybinds/persist/MacroStorage.java | 60 +++++++++++ .../keybinds/serialize/MacroCodec.java | 99 +++++++++++++++++++ 23 files changed, 655 insertions(+) create mode 100644 src/main/java/com/example/keybinds/core/action/Action.java create mode 100644 src/main/java/com/example/keybinds/core/action/ActionRegistry.java create mode 100644 src/main/java/com/example/keybinds/core/action/ActionType.java create mode 100644 src/main/java/com/example/keybinds/core/action/ActionTypes.java create mode 100644 src/main/java/com/example/keybinds/core/action/AttackAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/SelectSlotAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/UseAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/WaitAction.java create mode 100644 src/main/java/com/example/keybinds/core/macro/Macro.java create mode 100644 src/main/java/com/example/keybinds/core/macro/MacroRegistry.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/NotTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/Trigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/TriggerType.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java create mode 100644 src/main/java/com/example/keybinds/persist/MacroStorage.java create mode 100644 src/main/java/com/example/keybinds/serialize/MacroCodec.java diff --git a/src/main/java/com/example/KeybindsClient.java b/src/main/java/com/example/KeybindsClient.java index 8b95669..c2902d4 100644 --- a/src/main/java/com/example/KeybindsClient.java +++ b/src/main/java/com/example/KeybindsClient.java @@ -1,6 +1,10 @@ package com.example; +import com.example.keybinds.core.action.ActionTypes; import com.example.keybinds.core.condition.PlayerConditions; +import com.example.keybinds.core.macro.MacroRegistry; +import com.example.keybinds.core.trigger.TriggerTypes; +import com.example.keybinds.persist.MacroStorage; import com.example.keybinds.core.input.EdgeDetector; import com.example.keybinds.core.schedule.TickScheduler; import com.example.keybinds.game.PlayerActions; @@ -52,6 +56,10 @@ public class KeybindsClient implements ClientModInitializer { @Override public void onInitializeClient() { + ActionTypes.bootstrap(); + TriggerTypes.bootstrap(); + MacroRegistry.get().replaceAll(MacroStorage.load()); + ClientTickEvents.END_CLIENT_TICK.register(client -> { // Scheduled steps run regardless of screen state so an in-progress swap always completes. scheduler.tick(); diff --git a/src/main/java/com/example/keybinds/core/action/Action.java b/src/main/java/com/example/keybinds/core/action/Action.java new file mode 100644 index 0000000..5be19e7 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/Action.java @@ -0,0 +1,10 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public interface Action { + + ActionType type(); + + void write(JsonObject out); +} diff --git a/src/main/java/com/example/keybinds/core/action/ActionRegistry.java b/src/main/java/com/example/keybinds/core/action/ActionRegistry.java new file mode 100644 index 0000000..6b188c0 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ActionRegistry.java @@ -0,0 +1,49 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.util.GsonHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ActionRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + private static final Map TYPES = new LinkedHashMap<>(); + + private ActionRegistry() { + } + + public static ActionType register(ActionType type) { + TYPES.put(type.id(), type); + return type; + } + + public static ActionType byId(String id) { + return TYPES.get(id); + } + + public static List ids() { + return List.copyOf(TYPES.keySet()); + } + + public static JsonObject toJson(Action action) { + JsonObject out = new JsonObject(); + out.addProperty("type", action.type().id()); + action.write(out); + return out; + } + + public static Action fromJson(JsonObject json) { + String id = GsonHelper.getAsString(json, "type", ""); + ActionType type = TYPES.get(id); + if (type == null) { + LOGGER.warn("Unknown action type '{}', skipping", id); + return null; + } + return type.reader().apply(json); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/ActionType.java b/src/main/java/com/example/keybinds/core/action/ActionType.java new file mode 100644 index 0000000..e07dff7 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ActionType.java @@ -0,0 +1,8 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +import java.util.function.Function; + +public record ActionType(String id, Function reader) { +} diff --git a/src/main/java/com/example/keybinds/core/action/ActionTypes.java b/src/main/java/com/example/keybinds/core/action/ActionTypes.java new file mode 100644 index 0000000..17ff6c8 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ActionTypes.java @@ -0,0 +1,24 @@ +package com.example.keybinds.core.action; + +import net.minecraft.util.GsonHelper; + +public final class ActionTypes { + + public static final ActionType WAIT = ActionRegistry.register(new ActionType("wait", + json -> new WaitAction(GsonHelper.getAsInt(json, "ticks", 1)))); + + public static final ActionType SELECT_SLOT = ActionRegistry.register(new ActionType("select_slot", + json -> new SelectSlotAction(GsonHelper.getAsInt(json, "slot", 0)))); + + public static final ActionType ATTACK = ActionRegistry.register(new ActionType("attack", + json -> new AttackAction())); + + public static final ActionType USE = ActionRegistry.register(new ActionType("use", + json -> new UseAction())); + + private ActionTypes() { + } + + public static void bootstrap() { + } +} diff --git a/src/main/java/com/example/keybinds/core/action/AttackAction.java b/src/main/java/com/example/keybinds/core/action/AttackAction.java new file mode 100644 index 0000000..3986fa9 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/AttackAction.java @@ -0,0 +1,15 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public record AttackAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.ATTACK; + } + + @Override + public void write(JsonObject out) { + } +} diff --git a/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java b/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java new file mode 100644 index 0000000..8c4f438 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public record SelectSlotAction(int slot) implements Action { + + @Override + public ActionType type() { + return ActionTypes.SELECT_SLOT; + } + + @Override + public void write(JsonObject out) { + out.addProperty("slot", slot); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/UseAction.java b/src/main/java/com/example/keybinds/core/action/UseAction.java new file mode 100644 index 0000000..f816c3b --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/UseAction.java @@ -0,0 +1,15 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public record UseAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.USE; + } + + @Override + public void write(JsonObject out) { + } +} diff --git a/src/main/java/com/example/keybinds/core/action/WaitAction.java b/src/main/java/com/example/keybinds/core/action/WaitAction.java new file mode 100644 index 0000000..0f0d449 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/WaitAction.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public record WaitAction(int ticks) implements Action { + + @Override + public ActionType type() { + return ActionTypes.WAIT; + } + + @Override + public void write(JsonObject out) { + out.addProperty("ticks", ticks); + } +} diff --git a/src/main/java/com/example/keybinds/core/macro/Macro.java b/src/main/java/com/example/keybinds/core/macro/Macro.java new file mode 100644 index 0000000..5a282b4 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/macro/Macro.java @@ -0,0 +1,61 @@ +package com.example.keybinds.core.macro; + +import com.example.keybinds.core.action.Action; +import com.example.keybinds.core.trigger.Trigger; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class Macro { + + private final UUID id; + private String name; + private boolean enabled; + private Trigger trigger; + private final List actions; + + public Macro(UUID id, String name, boolean enabled, Trigger trigger, List actions) { + this.id = id; + this.name = name; + this.enabled = enabled; + this.trigger = trigger; + this.actions = new ArrayList<>(actions); + } + + public static Macro create(String name) { + return new Macro(UUID.randomUUID(), name, true, null, List.of()); + } + + public UUID id() { + return id; + } + + public String name() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean enabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Trigger trigger() { + return trigger; + } + + public void setTrigger(Trigger trigger) { + this.trigger = trigger; + } + + public List actions() { + return actions; + } +} diff --git a/src/main/java/com/example/keybinds/core/macro/MacroRegistry.java b/src/main/java/com/example/keybinds/core/macro/MacroRegistry.java new file mode 100644 index 0000000..03263ba --- /dev/null +++ b/src/main/java/com/example/keybinds/core/macro/MacroRegistry.java @@ -0,0 +1,43 @@ +package com.example.keybinds.core.macro; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public final class MacroRegistry { + + private static final MacroRegistry INSTANCE = new MacroRegistry(); + + private final List macros = new ArrayList<>(); + + public static MacroRegistry get() { + return INSTANCE; + } + + public List all() { + return Collections.unmodifiableList(macros); + } + + public Macro byId(UUID id) { + for (Macro macro : macros) { + if (macro.id().equals(id)) { + return macro; + } + } + return null; + } + + public void add(Macro macro) { + macros.add(macro); + } + + public void remove(Macro macro) { + macros.remove(macro); + } + + public void replaceAll(List replacement) { + macros.clear(); + macros.addAll(replacement); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java b/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java new file mode 100644 index 0000000..be4a48d --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java @@ -0,0 +1,23 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; + +public record AllOfTrigger(List children) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.ALL_OF; + } + + @Override + public void write(JsonObject out) { + JsonArray array = new JsonArray(); + for (Trigger child : children) { + array.add(TriggerRegistry.toJson(child)); + } + out.add("children", array); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java b/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java new file mode 100644 index 0000000..613ce3a --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java @@ -0,0 +1,23 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; + +public record AnyOfTrigger(List children) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.ANY_OF; + } + + @Override + public void write(JsonObject out) { + JsonArray array = new JsonArray(); + for (Trigger child : children) { + array.add(TriggerRegistry.toJson(child)); + } + out.add("children", array); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java b/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java new file mode 100644 index 0000000..f464c11 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record ConditionTrigger(String condition) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.CONDITION; + } + + @Override + public void write(JsonObject out) { + out.addProperty("condition", condition); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java b/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java new file mode 100644 index 0000000..ff8ab1e --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record KeyTrigger(int key) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.KEY; + } + + @Override + public void write(JsonObject out) { + out.addProperty("key", key); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java b/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java new file mode 100644 index 0000000..40c801a --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record MouseButtonTrigger(int button) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.MOUSE_BUTTON; + } + + @Override + public void write(JsonObject out) { + out.addProperty("button", button); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java b/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java new file mode 100644 index 0000000..daf1751 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java @@ -0,0 +1,16 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record NotTrigger(Trigger child) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.NOT; + } + + @Override + public void write(JsonObject out) { + out.add("child", TriggerRegistry.toJson(child)); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/Trigger.java b/src/main/java/com/example/keybinds/core/trigger/Trigger.java new file mode 100644 index 0000000..05d5235 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/Trigger.java @@ -0,0 +1,10 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public interface Trigger { + + TriggerType type(); + + void write(JsonObject out); +} diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java b/src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java new file mode 100644 index 0000000..cb6d9cf --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java @@ -0,0 +1,49 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; +import net.minecraft.util.GsonHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class TriggerRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + private static final Map TYPES = new LinkedHashMap<>(); + + private TriggerRegistry() { + } + + public static TriggerType register(TriggerType type) { + TYPES.put(type.id(), type); + return type; + } + + public static TriggerType byId(String id) { + return TYPES.get(id); + } + + public static List ids() { + return List.copyOf(TYPES.keySet()); + } + + public static JsonObject toJson(Trigger trigger) { + JsonObject out = new JsonObject(); + out.addProperty("type", trigger.type().id()); + trigger.write(out); + return out; + } + + public static Trigger fromJson(JsonObject json) { + String id = GsonHelper.getAsString(json, "type", ""); + TriggerType type = TYPES.get(id); + if (type == null) { + LOGGER.warn("Unknown trigger type '{}', skipping", id); + return null; + } + return type.reader().apply(json); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerType.java b/src/main/java/com/example/keybinds/core/trigger/TriggerType.java new file mode 100644 index 0000000..ed125b2 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerType.java @@ -0,0 +1,8 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +import java.util.function.Function; + +public record TriggerType(String id, Function reader) { +} diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java new file mode 100644 index 0000000..d311cae --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java @@ -0,0 +1,54 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minecraft.util.GsonHelper; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; + +public final class TriggerTypes { + + public static final TriggerType KEY = TriggerRegistry.register(new TriggerType("key", + json -> new KeyTrigger(GsonHelper.getAsInt(json, "key", GLFW.GLFW_KEY_UNKNOWN)))); + + public static final TriggerType MOUSE_BUTTON = TriggerRegistry.register(new TriggerType("mouse_button", + json -> new MouseButtonTrigger(GsonHelper.getAsInt(json, "button", -1)))); + + public static final TriggerType CONDITION = TriggerRegistry.register(new TriggerType("condition", + json -> new ConditionTrigger(GsonHelper.getAsString(json, "condition", "")))); + + public static final TriggerType ALL_OF = TriggerRegistry.register(new TriggerType("all_of", + json -> new AllOfTrigger(readChildren(json)))); + + public static final TriggerType ANY_OF = TriggerRegistry.register(new TriggerType("any_of", + json -> new AnyOfTrigger(readChildren(json)))); + + public static final TriggerType NOT = TriggerRegistry.register(new TriggerType("not", + json -> { + Trigger child = json.get("child") instanceof JsonObject childJson + ? TriggerRegistry.fromJson(childJson) : null; + return child == null ? null : new NotTrigger(child); + })); + + private TriggerTypes() { + } + + public static void bootstrap() { + } + + private static List readChildren(JsonObject json) { + List children = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(json, "children", new JsonArray())) { + if (element instanceof JsonObject childJson) { + Trigger child = TriggerRegistry.fromJson(childJson); + if (child != null) { + children.add(child); + } + } + } + return children; + } +} diff --git a/src/main/java/com/example/keybinds/persist/MacroStorage.java b/src/main/java/com/example/keybinds/persist/MacroStorage.java new file mode 100644 index 0000000..47c23d8 --- /dev/null +++ b/src/main/java/com/example/keybinds/persist/MacroStorage.java @@ -0,0 +1,60 @@ +package com.example.keybinds.persist; + +import com.example.keybinds.core.macro.Macro; +import com.example.keybinds.serialize.MacroCodec; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.loader.api.FabricLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; + +public final class MacroStorage { + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private MacroStorage() { + } + + public static Path path() { + return FabricLoader.getInstance().getConfigDir().resolve("macros.json"); + } + + public static List load() { + Path path = path(); + if (!Files.exists(path)) { + return List.of(); + } + try { + String content = Files.readString(path); + if (JsonParser.parseString(content) instanceof JsonObject root) { + return MacroCodec.read(root); + } + LOGGER.warn("macros.json root is not an object, not loading"); + return List.of(); + } catch (Exception e) { + LOGGER.error("Failed to load macros.json", e); + return List.of(); + } + } + + public static void save(List macros) { + Path path = path(); + try { + Files.createDirectories(path.getParent()); + Path temp = path.resolveSibling("macros.json.tmp"); + Files.writeString(temp, GSON.toJson(MacroCodec.write(macros))); + Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOGGER.error("Failed to save macros.json", e); + } + } +} diff --git a/src/main/java/com/example/keybinds/serialize/MacroCodec.java b/src/main/java/com/example/keybinds/serialize/MacroCodec.java new file mode 100644 index 0000000..45ab356 --- /dev/null +++ b/src/main/java/com/example/keybinds/serialize/MacroCodec.java @@ -0,0 +1,99 @@ +package com.example.keybinds.serialize; + +import com.example.keybinds.core.action.Action; +import com.example.keybinds.core.action.ActionRegistry; +import com.example.keybinds.core.macro.Macro; +import com.example.keybinds.core.trigger.Trigger; +import com.example.keybinds.core.trigger.TriggerRegistry; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minecraft.util.GsonHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class MacroCodec { + + public static final int FORMAT_VERSION = 1; + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + + private MacroCodec() { + } + + public static JsonObject write(List macros) { + JsonObject root = new JsonObject(); + root.addProperty("formatVersion", FORMAT_VERSION); + JsonArray array = new JsonArray(); + for (Macro macro : macros) { + array.add(writeMacro(macro)); + } + root.add("macros", array); + return root; + } + + public static List read(JsonObject root) { + int version = GsonHelper.getAsInt(root, "formatVersion", FORMAT_VERSION); + if (version > FORMAT_VERSION) { + LOGGER.warn("macros.json format version {} is newer than supported version {}, not loading", + version, FORMAT_VERSION); + return List.of(); + } + List macros = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(root, "macros", new JsonArray())) { + if (element instanceof JsonObject json) { + Macro macro = readMacro(json); + if (macro != null) { + macros.add(macro); + } + } + } + return macros; + } + + private static JsonObject writeMacro(Macro macro) { + JsonObject json = new JsonObject(); + json.addProperty("id", macro.id().toString()); + json.addProperty("name", macro.name()); + json.addProperty("enabled", macro.enabled()); + if (macro.trigger() != null) { + json.add("trigger", TriggerRegistry.toJson(macro.trigger())); + } + JsonArray actions = new JsonArray(); + for (Action action : macro.actions()) { + actions.add(ActionRegistry.toJson(action)); + } + json.add("actions", actions); + return json; + } + + private static Macro readMacro(JsonObject json) { + UUID id = parseId(GsonHelper.getAsString(json, "id", "")); + String name = GsonHelper.getAsString(json, "name", "Unnamed"); + boolean enabled = GsonHelper.getAsBoolean(json, "enabled", true); + Trigger trigger = json.get("trigger") instanceof JsonObject triggerJson + ? TriggerRegistry.fromJson(triggerJson) : null; + List actions = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(json, "actions", new JsonArray())) { + if (element instanceof JsonObject actionJson) { + Action action = ActionRegistry.fromJson(actionJson); + if (action != null) { + actions.add(action); + } + } + } + return new Macro(id, name, enabled, trigger, actions); + } + + private static UUID parseId(String raw) { + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + return UUID.randomUUID(); + } + } +} From 12cb6196238f71f4a0108ec78761f6f04b606eaf Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sat, 11 Jul 2026 21:13:14 -0500 Subject: [PATCH 04/19] add tick-based macro execution engine and re-express legacy features as compiled macros --- src/main/java/com/example/KeybindsClient.java | 521 ++---------------- src/main/java/com/example/KeybindsConfig.java | 11 +- .../example/keybinds/compat/ConfigMacros.java | 177 ++++++ .../example/keybinds/core/action/Action.java | 2 + .../keybinds/core/action/ActionContext.java | 41 ++ .../keybinds/core/action/ActionResult.java | 35 ++ .../keybinds/core/action/ActionTypes.java | 45 +- .../keybinds/core/action/AttackAction.java | 14 +- .../core/action/RequireItemAction.java | 40 ++ .../core/action/RestoreSlotAction.java | 24 + .../core/action/SelectItemAction.java | 47 ++ .../core/action/SelectSlotAction.java | 7 + .../core/action/ShieldTotemSwapAction.java | 58 ++ .../keybinds/core/action/SwapBackAction.java | 24 + .../core/action/SwapToItemAction.java | 91 +++ .../keybinds/core/action/UseAction.java | 8 + .../keybinds/core/action/UseItemAction.java | 114 ++++ .../keybinds/core/action/WaitAction.java | 5 + .../keybinds/core/exec/ExecutionEngine.java | 47 ++ .../keybinds/core/exec/MacroExecution.java | 58 ++ .../keybinds/core/trigger/AllOfTrigger.java | 12 + .../keybinds/core/trigger/AnyOfTrigger.java | 12 + .../core/trigger/ConditionTrigger.java | 6 + .../keybinds/core/trigger/HoldingTrigger.java | 28 + .../keybinds/core/trigger/KeyTrigger.java | 6 + .../core/trigger/MouseButtonTrigger.java | 6 + .../keybinds/core/trigger/NotTrigger.java | 6 + .../keybinds/core/trigger/Trigger.java | 2 + .../keybinds/core/trigger/TriggerContext.java | 65 +++ .../keybinds/core/trigger/TriggerResult.java | 4 + .../keybinds/core/trigger/TriggerTypes.java | 13 + .../com/example/keybinds/game/ItemLookup.java | 84 +++ 32 files changed, 1126 insertions(+), 487 deletions(-) create mode 100644 src/main/java/com/example/keybinds/compat/ConfigMacros.java create mode 100644 src/main/java/com/example/keybinds/core/action/ActionContext.java create mode 100644 src/main/java/com/example/keybinds/core/action/ActionResult.java create mode 100644 src/main/java/com/example/keybinds/core/action/RequireItemAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/SelectItemAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/SwapBackAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/SwapToItemAction.java create mode 100644 src/main/java/com/example/keybinds/core/action/UseItemAction.java create mode 100644 src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java create mode 100644 src/main/java/com/example/keybinds/core/exec/MacroExecution.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/TriggerContext.java create mode 100644 src/main/java/com/example/keybinds/core/trigger/TriggerResult.java create mode 100644 src/main/java/com/example/keybinds/game/ItemLookup.java diff --git a/src/main/java/com/example/KeybindsClient.java b/src/main/java/com/example/KeybindsClient.java index c2902d4..2c59d04 100644 --- a/src/main/java/com/example/KeybindsClient.java +++ b/src/main/java/com/example/KeybindsClient.java @@ -1,58 +1,38 @@ package com.example; +import com.example.keybinds.compat.ConfigMacros; import com.example.keybinds.core.action.ActionTypes; -import com.example.keybinds.core.condition.PlayerConditions; -import com.example.keybinds.core.macro.MacroRegistry; -import com.example.keybinds.core.trigger.TriggerTypes; -import com.example.keybinds.persist.MacroStorage; +import com.example.keybinds.core.exec.ExecutionEngine; import com.example.keybinds.core.input.EdgeDetector; +import com.example.keybinds.core.macro.Macro; +import com.example.keybinds.core.macro.MacroRegistry; import com.example.keybinds.core.schedule.TickScheduler; +import com.example.keybinds.core.trigger.TriggerContext; +import com.example.keybinds.core.trigger.TriggerTypes; import com.example.keybinds.game.PlayerActions; +import com.example.keybinds.persist.MacroStorage; import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.platform.Window; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.client.Minecraft; -import net.minecraft.client.multiplayer.MultiPlayerGameMode; import net.minecraft.client.player.LocalPlayer; -import net.minecraft.resources.Identifier; -import net.minecraft.tags.ItemTags; -import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.player.Inventory; -import net.minecraft.world.inventory.ContainerInput; -import net.minecraft.world.inventory.InventoryMenu; import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; -import net.minecraft.world.item.enchantment.ItemEnchantments; -import org.lwjgl.glfw.GLFW; import java.util.List; -/** - * Client entrypoint. Drives the player's own inventory and item use through the normal item-use / - * container packets, so everything works in singleplayer and on unmodified servers. - * - *

No vanilla {@link net.minecraft.client.KeyMapping} mappings are registered. Every key is stored - * in {@link KeybindsConfig} and polled each tick, which keeps binds fully dynamic: added, removed, - * and rebound live from the config screen with no restart. Legacy feature logic lives here until it - * is re-expressed through the macro engine (see docs/architecture.md, milestone M3); the reusable - * subsystems it sits on have been extracted to {@code com.example.keybinds.*}. - */ public class KeybindsClient implements ClientModInitializer { private static final int NONE = -1; - /** Offsets that let mouse buttons and per-bind condition state share the key-code edge space. */ - private static final int MOUSE_BASE = 1_000_000; - private static final int COND_BASE = 2_000_000; + private final TickScheduler scheduler = new TickScheduler(); + private final ExecutionEngine engine = new ExecutionEngine(); + private final TriggerContext triggers = new TriggerContext(); + private final EdgeDetector holdEdges = new EdgeDetector(); - private int slamReturnSlot = NONE; - private int pendingMaceSlot = NONE; private int activeHoldIndex = NONE; - - private final TickScheduler scheduler = new TickScheduler(); - private final EdgeDetector edges = new EdgeDetector(); + private List configMacros; @Override public void onInitializeClient() { @@ -61,34 +41,49 @@ public void onInitializeClient() { MacroRegistry.get().replaceAll(MacroStorage.load()); ClientTickEvents.END_CLIENT_TICK.register(client -> { - // Scheduled steps run regardless of screen state so an in-progress swap always completes. scheduler.tick(); + engine.tick(); - // The mace stun-slam spans two ticks: finish any slam started last tick first. - finishMaceStunSlam(client); - - // Only poll while actually in-world with no screen open; the grabbed mouse is the signal. if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) { if (activeHoldIndex != NONE) { releaseHold(client); } - edges.reset(); + triggers.reset(); + holdEdges.reset(); + configMacros = null; return; } - pollInputs(client, KeybindsConfig.get()); + KeybindsConfig config = KeybindsConfig.get(); + if (configMacros == null) { + configMacros = ConfigMacros.build(config); + } + + triggers.begin(client); + pollHolds(client, config); + fireMacros(client, configMacros); + fireMacros(client, MacroRegistry.get().all()); + triggers.commit(); + holdEdges.commit(); }); } - // --- Key polling --------------------------------------------------------------------------- + private void fireMacros(Minecraft client, List macros) { + for (Macro macro : macros) { + if (!macro.enabled() || macro.trigger() == null) { + continue; + } + if (macro.trigger().evaluate(triggers).fired()) { + engine.start(macro, client, scheduler); + } + } + } - private void pollInputs(Minecraft client, KeybindsConfig config) { + private void pollHolds(Minecraft client, KeybindsConfig config) { Window window = client.getWindow(); List uses = config.itemUses; int count = Math.min(uses.size(), KeybindsConfig.MAX_ITEM_USES); - boolean resync = edges.resyncing(); - // Continue or end an in-progress hold before considering new presses. if (activeHoldIndex != NONE) { KeybindsConfig.ItemUse held = activeHoldIndex < count ? uses.get(activeHoldIndex) : null; boolean stillHeld = held != null && held.hold && KeybindsConfig.isBound(held.key) @@ -100,205 +95,21 @@ private void pollInputs(Minecraft client, KeybindsConfig config) { } } + boolean resync = holdEdges.resyncing(); for (int i = 0; i < count; i++) { KeybindsConfig.ItemUse use = uses.get(i); - - // Each enabled trigger is always polled (so its held state feeds next tick's edge - // detection) but only fires on a fresh down-transition outside a resync tick. - boolean fire = false; - String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; - - if (mode.equals(KeybindsConfig.TRIGGER_KEY)) { - if (KeybindsConfig.isBound(use.key)) { - boolean pressed = keyJustPressed(window, use.key); - if (!resync && pressed) { - if (use.hold) { - if (activeHoldIndex == NONE) { - Item item = KeybindsConfig.resolveItem(use.item); - if (item != null) { - startHold(client, i, item); - } - } - } else { - fire = true; - } - } - } - } else { - boolean rightClick = mode.equals(KeybindsConfig.TRIGGER_RIGHT_CLICK); - int button = rightClick ? GLFW.GLFW_MOUSE_BUTTON_RIGHT : GLFW.GLFW_MOUSE_BUTTON_LEFT; - boolean pressed = mouseJustPressed(window, button); - if (!resync && pressed && holdingClickItem(client, use)) { - // Right-click is vanilla's own use key: if the bind's item is already in the main - // hand, vanilla just used it on this very click, so firing again would double it. - if (!(rightClick && usedItemInMainHand(client, use))) { - fire = true; - } - } - } - - // Environment-condition triggers, fired on the onset of any listed condition (additive). - if (use.conditions != null && !use.conditions.isEmpty()) { - boolean active = false; - for (String id : use.conditions) { - if (PlayerConditions.active(id, client.player)) { - active = true; - break; - } - } - if (edges.justPressed(COND_BASE + i, active) && !resync) { - fire = true; - } - } - - if (fire) { - fireBind(client, use); - } - } - - if (KeybindsConfig.isBound(config.shieldTotemKey) - && keyJustPressed(window, config.shieldTotemKey) && !resync) { - if (config.shieldTotemVanilla) { - scheduler.schedule(config.combatSwapToDelay, () -> trySwapShieldTotem(client)); - } else { - trySwapShieldTotem(client); - } - } - if (KeybindsConfig.isBound(config.maceStunSlamKey) - && keyJustPressed(window, config.maceStunSlamKey) && !resync) { - startMaceStunSlam(client); - } - - // Lunge swap: every enabled trigger is polled unconditionally so its edge state is recorded. - boolean lungeFire = false; - if (KeybindsConfig.isBound(config.lungeSwapKey) && keyJustPressed(window, config.lungeSwapKey)) { - lungeFire = true; - } - if (config.lungeSwapLeftClick && mouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_LEFT)) { - lungeFire = true; - } - if (config.lungeSwapRightClick && mouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_RIGHT)) { - lungeFire = true; - } - if (lungeFire && !resync && holdingLungeItem(client, config)) { - startLungeSwap(client, config); - } - - edges.commit(); - } - - private boolean keyJustPressed(Window window, int key) { - return edges.justPressed(key, InputConstants.isKeyDown(window, key)); - } - - private boolean mouseJustPressed(Window window, int button) { - boolean down = GLFW.glfwGetMouseButton(window.handle(), button) == GLFW.GLFW_PRESS; - return edges.justPressed(MOUSE_BASE + button, down); - } - - private static boolean usedItemInMainHand(Minecraft client, KeybindsConfig.ItemUse use) { - LocalPlayer player = client.player; - Item item = KeybindsConfig.resolveItem(use.item); - return player != null && item != null && player.getMainHandItem().is(item); - } - - private static boolean holdingClickItem(Minecraft client, KeybindsConfig.ItemUse use) { - LocalPlayer player = client.player; - if (player == null) { - return false; - } - if (use.clickItems == null || use.clickItems.isEmpty()) { - return true; - } - ItemStack held = player.getMainHandItem(); - for (String id : use.clickItems) { - Item item = KeybindsConfig.resolveItem(id); - if (item != null && held.is(item)) { - return true; + if (!use.hold || !mode.equals(KeybindsConfig.TRIGGER_KEY) || !KeybindsConfig.isBound(use.key)) { + continue; } - } - return false; - } - - // --- Use-item binds ------------------------------------------------------------------------ - - private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - Item item = KeybindsConfig.resolveItem(use.item); - if (item == null) { - return; - } - - Inventory inventory = player.getInventory(); - boolean inMainHand = player.getMainHandItem().is(item); - boolean inHands = inMainHand || player.getOffhandItem().is(item); - int hotbarSlot = PlayerActions.findHotbarSlot(inventory, item); - boolean onHotbar = inHands || hotbarSlot >= 0; - - String gate = use.hotbarGate == null ? KeybindsConfig.GATE_ANY : use.hotbarGate; - if (gate.equals(KeybindsConfig.GATE_IN_HOTBAR) && !onHotbar) { - return; - } - if (gate.equals(KeybindsConfig.GATE_NOT_IN_HOTBAR) && onHotbar) { - return; - } - - int buriedSlot = NONE; - if (!onHotbar && player.containerMenu == player.inventoryMenu) { - for (int slot = Inventory.SELECTION_SIZE; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { - if (inventory.getItem(slot).is(item)) { - buriedSlot = slot; - break; + boolean pressed = holdEdges.justPressed(use.key, InputConstants.isKeyDown(window, use.key)); + if (!resync && pressed && activeHoldIndex == NONE) { + Item item = KeybindsConfig.resolveItem(use.item); + if (item != null) { + startHold(client, i, item); } } } - - if (use.vanillaSwap && buriedSlot != NONE) { - scheduleVanillaSwap(client, use, buriedSlot); - } else if (use.vanillaSwap && !inMainHand && hotbarSlot >= 0) { - scheduleVanillaHotbarUse(client, use, hotbarSlot); - } else { - PlayerActions.useItemInstant(client, item); - } - } - - private void scheduleVanillaHotbarUse(Minecraft client, KeybindsConfig.ItemUse use, int hotbarSlot) { - LocalPlayer player = client.player; - if (player == null) { - return; - } - int previousSlot = player.getInventory().getSelectedSlot(); - - int atSwapTo = use.swapToDelay; - int atUse = atSwapTo + use.useDelay; - int atSwapBack = atUse + use.swapBackDelay; - - scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, hotbarSlot)); - scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); - scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); - } - - private void scheduleVanillaSwap(Minecraft client, KeybindsConfig.ItemUse use, int buriedSlot) { - LocalPlayer player = client.player; - if (player == null || player.containerMenu != player.inventoryMenu) { - return; - } - Inventory inventory = player.getInventory(); - int hotbarSlot = inventory.getSelectedSlot(); - int containerId = player.inventoryMenu.containerId; - - int atSwapTo = use.swapToDelay; - int atUse = atSwapTo + use.useDelay; - int atSwapBack = atUse + use.swapBackDelay; - - scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); - scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); - scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); } private void startHold(Minecraft client, int index, Item item) { @@ -307,7 +118,6 @@ private void startHold(Minecraft client, int index, Item item) { return; } Inventory inventory = player.getInventory(); - int slot = PlayerActions.findHotbarSlot(inventory, item); if (slot < 0) { if (!player.getMainHandItem().is(item)) { @@ -315,254 +125,15 @@ private void startHold(Minecraft client, int index, Item item) { } slot = inventory.getSelectedSlot(); } - PlayerActions.selectHotbarSlot(client, inventory, slot); activeHoldIndex = index; client.options.keyUse.setDown(true); } - /** - * End a hold-mode use so the action finishes (a drawn bow fires here). The selected slot is - * intentionally left as-is — switching hotbar slots cancels an in-progress use. - */ private void releaseHold(Minecraft client) { activeHoldIndex = NONE; if (client.options != null) { client.options.keyUse.setDown(false); } } - - // --- Shield / totem swap ------------------------------------------------------------------- - - private static void trySwapShieldTotem(Minecraft client) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null) { - return; - } - if (player.containerMenu != player.inventoryMenu) { - return; - } - - Inventory inventory = player.getInventory(); - int containerId = player.inventoryMenu.containerId; - ItemStack offhand = player.getItemInHand(InteractionHand.OFF_HAND); - - if (offhand.is(Items.SHIELD)) { - int totemSlot = PlayerActions.findItem(inventory, Items.TOTEM_OF_UNDYING); - if (totemSlot >= 0) { - PlayerActions.swapWithOffhand(gameMode, containerId, player, totemSlot); - } else { - gameMode.handleContainerInput( - containerId, InventoryMenu.SHIELD_SLOT, 0, ContainerInput.QUICK_MOVE, player); - } - } else { - int shieldSlot = PlayerActions.findItem(inventory, Items.SHIELD); - if (shieldSlot >= 0) { - PlayerActions.swapWithOffhand(gameMode, containerId, player, shieldSlot); - } - } - } - - // --- Mace stun-slam ------------------------------------------------------------------------ - - private void startMaceStunSlam(Minecraft client) { - LocalPlayer player = client.player; - MultiPlayerGameMode gameMode = client.gameMode; - if (player == null || gameMode == null || pendingMaceSlot != NONE) { - return; - } - - Inventory inventory = player.getInventory(); - int axeSlot = PlayerActions.findHotbarSlot(inventory, ItemTags.AXES); - int maceSlot = PlayerActions.findHotbarSlot(inventory, Items.MACE); - if (axeSlot < 0 || maceSlot < 0) { - return; - } - - int returnSlot = inventory.getSelectedSlot(); - KeybindsConfig config = KeybindsConfig.get(); - - if (config.maceStunSlamVanilla) { - int atAxe = config.combatSwapToDelay; - int atMace = atAxe + config.combatUseDelay; - int atBack = atMace + config.combatSwapBackDelay; - scheduler.schedule(atAxe, () -> { - PlayerActions.selectHotbarSlotIfValid(client, axeSlot); - PlayerActions.attackCrosshairTarget(client); - }); - scheduler.schedule(atMace, () -> { - PlayerActions.selectHotbarSlotIfValid(client, maceSlot); - PlayerActions.attackCrosshairTarget(client); - }); - if (config.returnToStartSlotAfterSlam) { - scheduler.schedule(atBack, () -> PlayerActions.selectHotbarSlotIfValid(client, returnSlot)); - } - return; - } - - // Fast path: axe hit now, mace hit on the very next tick (finishMaceStunSlam). - slamReturnSlot = returnSlot; - pendingMaceSlot = maceSlot; - - PlayerActions.selectHotbarSlot(client, inventory, axeSlot); - PlayerActions.attackCrosshairTarget(client); - } - - private void finishMaceStunSlam(Minecraft client) { - if (pendingMaceSlot == NONE) { - return; - } - int maceSlot = pendingMaceSlot; - int returnSlot = slamReturnSlot; - pendingMaceSlot = NONE; - slamReturnSlot = NONE; - - LocalPlayer player = client.player; - if (player == null || client.gameMode == null) { - return; - } - Inventory inventory = player.getInventory(); - - PlayerActions.selectHotbarSlot(client, inventory, maceSlot); - PlayerActions.attackCrosshairTarget(client); - - if (KeybindsConfig.get().returnToStartSlotAfterSlam && returnSlot != NONE) { - PlayerActions.selectHotbarSlot(client, inventory, returnSlot); - } - } - - // --- Lunge swap ---------------------------------------------------------------------------- - - private static boolean holdingLungeItem(Minecraft client, KeybindsConfig config) { - LocalPlayer player = client.player; - if (player == null) { - return false; - } - List ids = config.lungeSwapHeldItems; - if (ids == null || ids.isEmpty()) { - return true; - } - ItemStack held = player.getMainHandItem(); - for (String id : ids) { - Item item = KeybindsConfig.resolveItem(id); - if (item != null && held.is(item)) { - return true; - } - } - return false; - } - - private void startLungeSwap(Minecraft client, KeybindsConfig config) { - LocalPlayer player = client.player; - if (player == null || client.gameMode == null) { - return; - } - Inventory inventory = player.getInventory(); - - // Highest-lunge spear across the whole inventory; ties keep the lower slot (hotbar first). - int bestSlot = NONE; - int bestLevel = Integer.MIN_VALUE; - for (int slot = 0; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { - ItemStack stack = inventory.getItem(slot); - if (!isSpear(config, stack)) { - continue; - } - int level = lungeLevel(config, stack); - if (level > bestLevel) { - bestLevel = level; - bestSlot = slot; - } - } - if (bestSlot == NONE) { - return; - } - - if (bestSlot < Inventory.SELECTION_SIZE) { - lungeFromHotbar(client, config, bestSlot); - } else { - lungeFromBuried(client, config, bestSlot); - } - } - - private void lungeFromHotbar(Minecraft client, KeybindsConfig config, int spearSlot) { - LocalPlayer player = client.player; - if (player == null) { - return; - } - int previousSlot = player.getInventory().getSelectedSlot(); - - if (!config.lungeSwapVanilla) { - Inventory inventory = player.getInventory(); - PlayerActions.selectHotbarSlot(client, inventory, spearSlot); - PlayerActions.attackCrosshairOrSwing(client); - PlayerActions.selectHotbarSlot(client, inventory, previousSlot); - return; - } - - int atSwapTo = config.combatSwapToDelay; - int atUse = atSwapTo + config.combatUseDelay; - int atSwapBack = atUse + config.combatSwapBackDelay; - scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, spearSlot)); - scheduler.schedule(atUse, () -> PlayerActions.attackCrosshairOrSwing(client)); - scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); - } - - private void lungeFromBuried(Minecraft client, KeybindsConfig config, int buriedSlot) { - LocalPlayer player = client.player; - if (player == null || player.containerMenu != player.inventoryMenu) { - return; - } - Inventory inventory = player.getInventory(); - int hotbarSlot = inventory.getSelectedSlot(); - int containerId = player.inventoryMenu.containerId; - - if (!config.lungeSwapVanilla) { - PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); - PlayerActions.attackCrosshairOrSwing(client); - PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot); - return; - } - - int atSwapTo = config.combatSwapToDelay; - int atUse = atSwapTo + config.combatUseDelay; - int atSwapBack = atUse + config.combatSwapBackDelay; - scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); - scheduler.schedule(atUse, () -> PlayerActions.attackCrosshairOrSwing(client)); - scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); - } - - private static boolean isSpear(KeybindsConfig config, ItemStack stack) { - if (stack.isEmpty()) { - return false; - } - List ids = config.spearItems; - if (ids == null || ids.isEmpty()) { - return lungeLevel(config, stack) > 0; - } - for (String id : ids) { - Item item = KeybindsConfig.resolveItem(id); - if (item != null && stack.is(item)) { - return true; - } - } - return false; - } - - private static int lungeLevel(KeybindsConfig config, ItemStack stack) { - if (stack.isEmpty() || config.lungeEnchant == null || config.lungeEnchant.isBlank()) { - return 0; - } - Identifier target = Identifier.tryParse(config.lungeEnchant.trim()); - if (target == null) { - return 0; - } - ItemEnchantments enchantments = stack.getEnchantments(); - for (var entry : enchantments.entrySet()) { - if (entry.getKey().is(target)) { - return entry.getIntValue(); - } - } - return 0; - } } diff --git a/src/main/java/com/example/KeybindsConfig.java b/src/main/java/com/example/KeybindsConfig.java index 8fb9ff9..436af88 100644 --- a/src/main/java/com/example/KeybindsConfig.java +++ b/src/main/java/com/example/KeybindsConfig.java @@ -3,8 +3,6 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.resources.Identifier; import net.minecraft.world.item.Item; import org.lwjgl.glfw.GLFW; @@ -182,14 +180,7 @@ public static boolean isBound(int key) { /** Resolve an item id string to its {@link Item}, or {@code null} if it is blank or unknown. */ public static Item resolveItem(String id) { - if (id == null || id.isBlank()) { - return null; - } - Identifier rl = Identifier.tryParse(id.trim()); - if (rl == null) { - return null; - } - return BuiltInRegistries.ITEM.getOptional(rl).orElse(null); + return com.example.keybinds.game.ItemLookup.resolveItem(id); } // --- Persistence --------------------------------------------------------------------------- diff --git a/src/main/java/com/example/keybinds/compat/ConfigMacros.java b/src/main/java/com/example/keybinds/compat/ConfigMacros.java new file mode 100644 index 0000000..7a59fdb --- /dev/null +++ b/src/main/java/com/example/keybinds/compat/ConfigMacros.java @@ -0,0 +1,177 @@ +package com.example.keybinds.compat; + +import com.example.KeybindsConfig; +import com.example.keybinds.core.action.Action; +import com.example.keybinds.core.action.AttackAction; +import com.example.keybinds.core.action.RequireItemAction; +import com.example.keybinds.core.action.RestoreSlotAction; +import com.example.keybinds.core.action.SelectItemAction; +import com.example.keybinds.core.action.ShieldTotemSwapAction; +import com.example.keybinds.core.action.SwapBackAction; +import com.example.keybinds.core.action.SwapToItemAction; +import com.example.keybinds.core.action.UseItemAction; +import com.example.keybinds.core.action.WaitAction; +import com.example.keybinds.core.macro.Macro; +import com.example.keybinds.core.trigger.AllOfTrigger; +import com.example.keybinds.core.trigger.AnyOfTrigger; +import com.example.keybinds.core.trigger.ConditionTrigger; +import com.example.keybinds.core.trigger.HoldingTrigger; +import com.example.keybinds.core.trigger.KeyTrigger; +import com.example.keybinds.core.trigger.MouseButtonTrigger; +import com.example.keybinds.core.trigger.NotTrigger; +import com.example.keybinds.core.trigger.Trigger; +import net.minecraft.tags.ItemTags; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class ConfigMacros { + + private ConfigMacros() { + } + + public static List build(KeybindsConfig config) { + List macros = new ArrayList<>(); + int count = Math.min(config.itemUses.size(), KeybindsConfig.MAX_ITEM_USES); + for (int i = 0; i < count; i++) { + addIfPresent(macros, itemUseMacro(config.itemUses.get(i), i)); + } + addIfPresent(macros, shieldTotemMacro(config)); + addIfPresent(macros, maceStunSlamMacro(config)); + addIfPresent(macros, lungeSwapMacro(config)); + return macros; + } + + private static void addIfPresent(List macros, Macro macro) { + if (macro != null) { + macros.add(macro); + } + } + + private static Macro itemUseMacro(KeybindsConfig.ItemUse use, int index) { + String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; + + Trigger primary = null; + if (mode.equals(KeybindsConfig.TRIGGER_KEY)) { + if (!use.hold && KeybindsConfig.isBound(use.key)) { + primary = new KeyTrigger(use.key); + } + } else { + boolean rightClick = mode.equals(KeybindsConfig.TRIGGER_RIGHT_CLICK); + int button = rightClick ? GLFW.GLFW_MOUSE_BUTTON_RIGHT : GLFW.GLFW_MOUSE_BUTTON_LEFT; + List parts = new ArrayList<>(); + parts.add(new MouseButtonTrigger(button)); + if (use.clickItems != null && !use.clickItems.isEmpty()) { + parts.add(new HoldingTrigger(List.copyOf(use.clickItems))); + } + if (rightClick && use.item != null && !use.item.isBlank()) { + parts.add(new NotTrigger(new HoldingTrigger(List.of(use.item)))); + } + primary = parts.size() == 1 ? parts.get(0) : new AllOfTrigger(parts); + } + + List sources = new ArrayList<>(); + if (primary != null) { + sources.add(primary); + } + if (use.conditions != null) { + for (String condition : use.conditions) { + sources.add(new ConditionTrigger(condition)); + } + } + if (sources.isEmpty()) { + return null; + } + Trigger trigger = sources.size() == 1 ? sources.get(0) : new AnyOfTrigger(sources); + + Action action = new UseItemAction(use.item, use.hotbarGate, use.vanillaSwap, + use.swapToDelay, use.useDelay, use.swapBackDelay); + return new Macro(UUID.randomUUID(), "Use item " + (index + 1), true, trigger, List.of(action)); + } + + private static Macro shieldTotemMacro(KeybindsConfig config) { + if (!KeybindsConfig.isBound(config.shieldTotemKey)) { + return null; + } + List actions = new ArrayList<>(); + if (config.shieldTotemVanilla) { + actions.add(new WaitAction(config.combatSwapToDelay)); + } + actions.add(new ShieldTotemSwapAction()); + return new Macro(UUID.randomUUID(), "Shield/totem swap", true, + new KeyTrigger(config.shieldTotemKey), actions); + } + + private static Macro maceStunSlamMacro(KeybindsConfig config) { + if (!KeybindsConfig.isBound(config.maceStunSlamKey)) { + return null; + } + String axesTag = ItemTags.AXES.location().toString(); + List mace = List.of("minecraft:mace"); + + List actions = new ArrayList<>(); + actions.add(new RequireItemAction(List.of(), axesTag)); + actions.add(new RequireItemAction(mace, "")); + if (config.maceStunSlamVanilla) { + actions.add(new WaitAction(config.combatSwapToDelay)); + actions.add(new SelectItemAction(List.of(), axesTag)); + actions.add(new AttackAction(false)); + actions.add(new WaitAction(config.combatUseDelay)); + actions.add(new SelectItemAction(mace, "")); + actions.add(new AttackAction(false)); + if (config.returnToStartSlotAfterSlam) { + actions.add(new WaitAction(config.combatSwapBackDelay)); + actions.add(new RestoreSlotAction()); + } + } else { + actions.add(new SelectItemAction(List.of(), axesTag)); + actions.add(new AttackAction(false)); + actions.add(new WaitAction(1)); + actions.add(new SelectItemAction(mace, "")); + actions.add(new AttackAction(false)); + if (config.returnToStartSlotAfterSlam) { + actions.add(new RestoreSlotAction()); + } + } + return new Macro(UUID.randomUUID(), "Mace stun slam", true, + new KeyTrigger(config.maceStunSlamKey), actions); + } + + private static Macro lungeSwapMacro(KeybindsConfig config) { + List sources = new ArrayList<>(); + if (KeybindsConfig.isBound(config.lungeSwapKey)) { + sources.add(new KeyTrigger(config.lungeSwapKey)); + } + if (config.lungeSwapLeftClick) { + sources.add(new MouseButtonTrigger(GLFW.GLFW_MOUSE_BUTTON_LEFT)); + } + if (config.lungeSwapRightClick) { + sources.add(new MouseButtonTrigger(GLFW.GLFW_MOUSE_BUTTON_RIGHT)); + } + if (sources.isEmpty()) { + return null; + } + Trigger trigger = sources.size() == 1 ? sources.get(0) : new AnyOfTrigger(sources); + if (config.lungeSwapHeldItems != null && !config.lungeSwapHeldItems.isEmpty()) { + trigger = new AllOfTrigger(List.of(trigger, new HoldingTrigger(List.copyOf(config.lungeSwapHeldItems)))); + } + + List spears = config.spearItems == null ? List.of() : List.copyOf(config.spearItems); + List actions = new ArrayList<>(); + if (config.lungeSwapVanilla) { + actions.add(new WaitAction(config.combatSwapToDelay)); + actions.add(new SwapToItemAction(spears, config.lungeEnchant)); + actions.add(new WaitAction(config.combatUseDelay)); + actions.add(new AttackAction(true)); + actions.add(new WaitAction(config.combatSwapBackDelay)); + actions.add(new SwapBackAction()); + } else { + actions.add(new SwapToItemAction(spears, config.lungeEnchant)); + actions.add(new AttackAction(true)); + actions.add(new SwapBackAction()); + } + return new Macro(UUID.randomUUID(), "Lunge swap", true, trigger, actions); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/Action.java b/src/main/java/com/example/keybinds/core/action/Action.java index 5be19e7..46944e3 100644 --- a/src/main/java/com/example/keybinds/core/action/Action.java +++ b/src/main/java/com/example/keybinds/core/action/Action.java @@ -7,4 +7,6 @@ public interface Action { ActionType type(); void write(JsonObject out); + + ActionResult execute(ActionContext ctx); } diff --git a/src/main/java/com/example/keybinds/core/action/ActionContext.java b/src/main/java/com/example/keybinds/core/action/ActionContext.java new file mode 100644 index 0000000..61bb5a9 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ActionContext.java @@ -0,0 +1,41 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.core.schedule.TickScheduler; +import net.minecraft.client.Minecraft; + +import java.util.ArrayDeque; +import java.util.Deque; + +public final class ActionContext { + + private final Minecraft client; + private final TickScheduler scheduler; + private final int startSlot; + private final Deque swapBacks = new ArrayDeque<>(); + + public ActionContext(Minecraft client, TickScheduler scheduler, int startSlot) { + this.client = client; + this.scheduler = scheduler; + this.startSlot = startSlot; + } + + public Minecraft client() { + return client; + } + + public TickScheduler scheduler() { + return scheduler; + } + + public int startSlot() { + return startSlot; + } + + public void pushSwapBack(Runnable undo) { + swapBacks.push(undo); + } + + public Runnable popSwapBack() { + return swapBacks.poll(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/ActionResult.java b/src/main/java/com/example/keybinds/core/action/ActionResult.java new file mode 100644 index 0000000..076b6b0 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ActionResult.java @@ -0,0 +1,35 @@ +package com.example.keybinds.core.action; + +public final class ActionResult { + + private static final ActionResult PROCEED = new ActionResult(false, 0); + private static final ActionResult ABORT = new ActionResult(true, 0); + + private final boolean aborted; + private final int delayTicks; + + private ActionResult(boolean aborted, int delayTicks) { + this.aborted = aborted; + this.delayTicks = delayTicks; + } + + public static ActionResult proceed() { + return PROCEED; + } + + public static ActionResult abort() { + return ABORT; + } + + public static ActionResult delay(int ticks) { + return ticks <= 0 ? PROCEED : new ActionResult(false, ticks); + } + + public boolean aborted() { + return aborted; + } + + public int delayTicks() { + return delayTicks; + } +} diff --git a/src/main/java/com/example/keybinds/core/action/ActionTypes.java b/src/main/java/com/example/keybinds/core/action/ActionTypes.java index 17ff6c8..49870e2 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionTypes.java +++ b/src/main/java/com/example/keybinds/core/action/ActionTypes.java @@ -1,7 +1,13 @@ package com.example.keybinds.core.action; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import net.minecraft.util.GsonHelper; +import java.util.ArrayList; +import java.util.List; + public final class ActionTypes { public static final ActionType WAIT = ActionRegistry.register(new ActionType("wait", @@ -10,15 +16,52 @@ public final class ActionTypes { public static final ActionType SELECT_SLOT = ActionRegistry.register(new ActionType("select_slot", json -> new SelectSlotAction(GsonHelper.getAsInt(json, "slot", 0)))); + public static final ActionType RESTORE_SLOT = ActionRegistry.register(new ActionType("restore_slot", + json -> new RestoreSlotAction())); + + public static final ActionType SELECT_ITEM = ActionRegistry.register(new ActionType("select_item", + json -> new SelectItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "tag", "")))); + + public static final ActionType REQUIRE_ITEM = ActionRegistry.register(new ActionType("require_item", + json -> new RequireItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "tag", "")))); + + public static final ActionType SWAP_TO_ITEM = ActionRegistry.register(new ActionType("swap_to_item", + json -> new SwapToItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "rankEnchant", "")))); + + public static final ActionType SWAP_BACK = ActionRegistry.register(new ActionType("swap_back", + json -> new SwapBackAction())); + public static final ActionType ATTACK = ActionRegistry.register(new ActionType("attack", - json -> new AttackAction())); + json -> new AttackAction(GsonHelper.getAsBoolean(json, "swing", false)))); public static final ActionType USE = ActionRegistry.register(new ActionType("use", json -> new UseAction())); + public static final ActionType USE_ITEM = ActionRegistry.register(new ActionType("use_item", + json -> new UseItemAction( + GsonHelper.getAsString(json, "item", ""), + GsonHelper.getAsString(json, "hotbarGate", UseItemAction.GATE_ANY), + GsonHelper.getAsBoolean(json, "vanillaSwap", false), + GsonHelper.getAsInt(json, "swapToDelay", 2), + GsonHelper.getAsInt(json, "useDelay", 3), + GsonHelper.getAsInt(json, "swapBackDelay", 2)))); + + public static final ActionType SHIELD_TOTEM_SWAP = ActionRegistry.register(new ActionType("shield_totem_swap", + json -> new ShieldTotemSwapAction())); + private ActionTypes() { } public static void bootstrap() { } + + private static List readStrings(JsonObject json, String field) { + List values = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) { + if (element.isJsonPrimitive()) { + values.add(element.getAsString()); + } + } + return values; + } } diff --git a/src/main/java/com/example/keybinds/core/action/AttackAction.java b/src/main/java/com/example/keybinds/core/action/AttackAction.java index 3986fa9..de56c32 100644 --- a/src/main/java/com/example/keybinds/core/action/AttackAction.java +++ b/src/main/java/com/example/keybinds/core/action/AttackAction.java @@ -1,8 +1,9 @@ package com.example.keybinds.core.action; +import com.example.keybinds.game.PlayerActions; import com.google.gson.JsonObject; -public record AttackAction() implements Action { +public record AttackAction(boolean swing) implements Action { @Override public ActionType type() { @@ -11,5 +12,16 @@ public ActionType type() { @Override public void write(JsonObject out) { + out.addProperty("swing", swing); + } + + @Override + public ActionResult execute(ActionContext ctx) { + if (swing) { + PlayerActions.attackCrosshairOrSwing(ctx.client()); + } else { + PlayerActions.attackCrosshairTarget(ctx.client()); + } + return ActionResult.proceed(); } } diff --git a/src/main/java/com/example/keybinds/core/action/RequireItemAction.java b/src/main/java/com/example/keybinds/core/action/RequireItemAction.java new file mode 100644 index 0000000..6a8ea38 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/RequireItemAction.java @@ -0,0 +1,40 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.game.ItemLookup; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.client.player.LocalPlayer; + +import java.util.List; + +public record RequireItemAction(List items, String tag) implements Action { + + @Override + public ActionType type() { + return ActionTypes.REQUIRE_ITEM; + } + + @Override + public void write(JsonObject out) { + if (items != null && !items.isEmpty()) { + JsonArray array = new JsonArray(); + for (String item : items) { + array.add(item); + } + out.add("items", array); + } + if (tag != null && !tag.isBlank()) { + out.addProperty("tag", tag); + } + } + + @Override + public ActionResult execute(ActionContext ctx) { + LocalPlayer player = ctx.client().player; + if (player == null) { + return ActionResult.abort(); + } + return ItemLookup.findHotbarSlot(player.getInventory(), items, tag) >= 0 + ? ActionResult.proceed() : ActionResult.abort(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java b/src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java new file mode 100644 index 0000000..bd473d8 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java @@ -0,0 +1,24 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.game.PlayerActions; +import com.google.gson.JsonObject; + +public record RestoreSlotAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.RESTORE_SLOT; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public ActionResult execute(ActionContext ctx) { + if (ctx.startSlot() >= 0) { + PlayerActions.selectHotbarSlotIfValid(ctx.client(), ctx.startSlot()); + } + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/SelectItemAction.java b/src/main/java/com/example/keybinds/core/action/SelectItemAction.java new file mode 100644 index 0000000..02d3675 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/SelectItemAction.java @@ -0,0 +1,47 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.game.ItemLookup; +import com.example.keybinds.game.PlayerActions; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; + +import java.util.List; + +public record SelectItemAction(List items, String tag) implements Action { + + @Override + public ActionType type() { + return ActionTypes.SELECT_ITEM; + } + + @Override + public void write(JsonObject out) { + if (items != null && !items.isEmpty()) { + JsonArray array = new JsonArray(); + for (String item : items) { + array.add(item); + } + out.add("items", array); + } + if (tag != null && !tag.isBlank()) { + out.addProperty("tag", tag); + } + } + + @Override + public ActionResult execute(ActionContext ctx) { + Minecraft client = ctx.client(); + LocalPlayer player = client.player; + if (player == null) { + return ActionResult.abort(); + } + int slot = ItemLookup.findHotbarSlot(player.getInventory(), items, tag); + if (slot < 0) { + return ActionResult.abort(); + } + PlayerActions.selectHotbarSlot(client, player.getInventory(), slot); + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java b/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java index 8c4f438..0a2c976 100644 --- a/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java +++ b/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java @@ -1,5 +1,6 @@ package com.example.keybinds.core.action; +import com.example.keybinds.game.PlayerActions; import com.google.gson.JsonObject; public record SelectSlotAction(int slot) implements Action { @@ -13,4 +14,10 @@ public ActionType type() { public void write(JsonObject out) { out.addProperty("slot", slot); } + + @Override + public ActionResult execute(ActionContext ctx) { + PlayerActions.selectHotbarSlotIfValid(ctx.client(), slot); + return ActionResult.proceed(); + } } diff --git a/src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java b/src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java new file mode 100644 index 0000000..6b61917 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java @@ -0,0 +1,58 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.game.PlayerActions; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +public record ShieldTotemSwapAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.SHIELD_TOTEM_SWAP; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public ActionResult execute(ActionContext ctx) { + Minecraft client = ctx.client(); + LocalPlayer player = client.player; + MultiPlayerGameMode gameMode = client.gameMode; + if (player == null || gameMode == null) { + return ActionResult.abort(); + } + if (player.containerMenu != player.inventoryMenu) { + return ActionResult.abort(); + } + + Inventory inventory = player.getInventory(); + int containerId = player.inventoryMenu.containerId; + ItemStack offhand = player.getItemInHand(InteractionHand.OFF_HAND); + + if (offhand.is(Items.SHIELD)) { + int totemSlot = PlayerActions.findItem(inventory, Items.TOTEM_OF_UNDYING); + if (totemSlot >= 0) { + PlayerActions.swapWithOffhand(gameMode, containerId, player, totemSlot); + } else { + gameMode.handleContainerInput( + containerId, InventoryMenu.SHIELD_SLOT, 0, ContainerInput.QUICK_MOVE, player); + } + } else { + int shieldSlot = PlayerActions.findItem(inventory, Items.SHIELD); + if (shieldSlot >= 0) { + PlayerActions.swapWithOffhand(gameMode, containerId, player, shieldSlot); + } + } + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/SwapBackAction.java b/src/main/java/com/example/keybinds/core/action/SwapBackAction.java new file mode 100644 index 0000000..655300f --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/SwapBackAction.java @@ -0,0 +1,24 @@ +package com.example.keybinds.core.action; + +import com.google.gson.JsonObject; + +public record SwapBackAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.SWAP_BACK; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public ActionResult execute(ActionContext ctx) { + Runnable undo = ctx.popSwapBack(); + if (undo != null) { + undo.run(); + } + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/SwapToItemAction.java b/src/main/java/com/example/keybinds/core/action/SwapToItemAction.java new file mode 100644 index 0000000..df3c91f --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/SwapToItemAction.java @@ -0,0 +1,91 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.game.ItemLookup; +import com.example.keybinds.game.PlayerActions; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; + +import java.util.List; + +public record SwapToItemAction(List items, String rankEnchant) implements Action { + + @Override + public ActionType type() { + return ActionTypes.SWAP_TO_ITEM; + } + + @Override + public void write(JsonObject out) { + if (items != null && !items.isEmpty()) { + JsonArray array = new JsonArray(); + for (String item : items) { + array.add(item); + } + out.add("items", array); + } + if (rankEnchant != null && !rankEnchant.isBlank()) { + out.addProperty("rankEnchant", rankEnchant); + } + } + + @Override + public ActionResult execute(ActionContext ctx) { + Minecraft client = ctx.client(); + LocalPlayer player = client.player; + if (player == null || client.gameMode == null) { + return ActionResult.abort(); + } + Inventory inventory = player.getInventory(); + + int bestSlot = findBestSlot(inventory); + if (bestSlot < 0) { + return ActionResult.abort(); + } + + if (bestSlot < Inventory.SELECTION_SIZE) { + int previousSlot = inventory.getSelectedSlot(); + PlayerActions.selectHotbarSlot(client, inventory, bestSlot); + ctx.pushSwapBack(() -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); + } else { + if (player.containerMenu != player.inventoryMenu) { + return ActionResult.abort(); + } + int hotbarSlot = inventory.getSelectedSlot(); + int containerId = player.inventoryMenu.containerId; + PlayerActions.swapBuriedWithHeld(client, containerId, bestSlot, hotbarSlot); + ctx.pushSwapBack(() -> PlayerActions.swapBuriedWithHeld(client, containerId, bestSlot, hotbarSlot)); + } + return ActionResult.proceed(); + } + + private int findBestSlot(Inventory inventory) { + boolean ranked = rankEnchant != null && !rankEnchant.isBlank(); + int bestSlot = -1; + int bestLevel = Integer.MIN_VALUE; + for (int slot = 0; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { + ItemStack stack = inventory.getItem(slot); + if (stack.isEmpty()) { + continue; + } + boolean candidate = items == null || items.isEmpty() + ? ranked && ItemLookup.enchantLevel(stack, rankEnchant) > 0 + : ItemLookup.matchesAny(stack, items); + if (!candidate) { + continue; + } + if (!ranked) { + return slot; + } + int level = ItemLookup.enchantLevel(stack, rankEnchant); + if (level > bestLevel) { + bestLevel = level; + bestSlot = slot; + } + } + return bestSlot; + } +} diff --git a/src/main/java/com/example/keybinds/core/action/UseAction.java b/src/main/java/com/example/keybinds/core/action/UseAction.java index f816c3b..6eef64a 100644 --- a/src/main/java/com/example/keybinds/core/action/UseAction.java +++ b/src/main/java/com/example/keybinds/core/action/UseAction.java @@ -1,6 +1,8 @@ package com.example.keybinds.core.action; +import com.example.keybinds.game.PlayerActions; import com.google.gson.JsonObject; +import net.minecraft.world.InteractionHand; public record UseAction() implements Action { @@ -12,4 +14,10 @@ public ActionType type() { @Override public void write(JsonObject out) { } + + @Override + public ActionResult execute(ActionContext ctx) { + PlayerActions.useHand(ctx.client(), InteractionHand.MAIN_HAND); + return ActionResult.proceed(); + } } diff --git a/src/main/java/com/example/keybinds/core/action/UseItemAction.java b/src/main/java/com/example/keybinds/core/action/UseItemAction.java new file mode 100644 index 0000000..d9d976f --- /dev/null +++ b/src/main/java/com/example/keybinds/core/action/UseItemAction.java @@ -0,0 +1,114 @@ +package com.example.keybinds.core.action; + +import com.example.keybinds.core.schedule.TickScheduler; +import com.example.keybinds.game.ItemLookup; +import com.example.keybinds.game.PlayerActions; +import com.google.gson.JsonObject; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; + +public record UseItemAction(String item, String hotbarGate, boolean vanillaSwap, + int swapToDelay, int useDelay, int swapBackDelay) implements Action { + + public static final String GATE_ANY = "any"; + public static final String GATE_IN_HOTBAR = "in_hotbar"; + public static final String GATE_NOT_IN_HOTBAR = "not_in_hotbar"; + + @Override + public ActionType type() { + return ActionTypes.USE_ITEM; + } + + @Override + public void write(JsonObject out) { + out.addProperty("item", item); + out.addProperty("hotbarGate", hotbarGate); + out.addProperty("vanillaSwap", vanillaSwap); + out.addProperty("swapToDelay", swapToDelay); + out.addProperty("useDelay", useDelay); + out.addProperty("swapBackDelay", swapBackDelay); + } + + @Override + public ActionResult execute(ActionContext ctx) { + Minecraft client = ctx.client(); + LocalPlayer player = client.player; + if (player == null || client.gameMode == null) { + return ActionResult.abort(); + } + Item resolved = ItemLookup.resolveItem(item); + if (resolved == null) { + return ActionResult.abort(); + } + + Inventory inventory = player.getInventory(); + boolean inMainHand = player.getMainHandItem().is(resolved); + boolean inHands = inMainHand || player.getOffhandItem().is(resolved); + int hotbarSlot = PlayerActions.findHotbarSlot(inventory, resolved); + boolean onHotbar = inHands || hotbarSlot >= 0; + + String gate = hotbarGate == null ? GATE_ANY : hotbarGate; + if (gate.equals(GATE_IN_HOTBAR) && !onHotbar) { + return ActionResult.abort(); + } + if (gate.equals(GATE_NOT_IN_HOTBAR) && onHotbar) { + return ActionResult.abort(); + } + + int buriedSlot = -1; + if (!onHotbar && player.containerMenu == player.inventoryMenu) { + for (int slot = Inventory.SELECTION_SIZE; slot < PlayerActions.MAIN_INVENTORY_END; slot++) { + if (inventory.getItem(slot).is(resolved)) { + buriedSlot = slot; + break; + } + } + } + + if (vanillaSwap && buriedSlot >= 0) { + scheduleBuriedSwap(ctx, buriedSlot); + } else if (vanillaSwap && !inMainHand && hotbarSlot >= 0) { + scheduleHotbarUse(ctx, hotbarSlot); + } else { + PlayerActions.useItemInstant(client, resolved); + } + return ActionResult.proceed(); + } + + private void scheduleHotbarUse(ActionContext ctx, int hotbarSlot) { + Minecraft client = ctx.client(); + int previousSlot = client.player.getInventory().getSelectedSlot(); + + int atSwapTo = swapToDelay; + int atUse = atSwapTo + useDelay; + int atSwapBack = atUse + swapBackDelay; + + TickScheduler scheduler = ctx.scheduler(); + scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, hotbarSlot)); + scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); + scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot)); + } + + private void scheduleBuriedSwap(ActionContext ctx, int buriedSlot) { + Minecraft client = ctx.client(); + LocalPlayer player = client.player; + if (player.containerMenu != player.inventoryMenu) { + return; + } + Inventory inventory = player.getInventory(); + int hotbarSlot = inventory.getSelectedSlot(); + int containerId = player.inventoryMenu.containerId; + + int atSwapTo = swapToDelay; + int atUse = atSwapTo + useDelay; + int atSwapBack = atUse + swapBackDelay; + + TickScheduler scheduler = ctx.scheduler(); + scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); + scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND)); + scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)); + } +} diff --git a/src/main/java/com/example/keybinds/core/action/WaitAction.java b/src/main/java/com/example/keybinds/core/action/WaitAction.java index 0f0d449..6d65450 100644 --- a/src/main/java/com/example/keybinds/core/action/WaitAction.java +++ b/src/main/java/com/example/keybinds/core/action/WaitAction.java @@ -13,4 +13,9 @@ public ActionType type() { public void write(JsonObject out) { out.addProperty("ticks", ticks); } + + @Override + public ActionResult execute(ActionContext ctx) { + return ActionResult.delay(ticks); + } } diff --git a/src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java b/src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java new file mode 100644 index 0000000..777b848 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java @@ -0,0 +1,47 @@ +package com.example.keybinds.core.exec; + +import com.example.keybinds.core.action.ActionContext; +import com.example.keybinds.core.macro.Macro; +import com.example.keybinds.core.schedule.TickScheduler; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; + +import java.util.ArrayList; +import java.util.List; + +public final class ExecutionEngine { + + private final List executions = new ArrayList<>(); + + public void tick() { + if (executions.isEmpty()) { + return; + } + for (int i = 0; i < executions.size(); i++) { + executions.get(i).tick(); + } + executions.removeIf(MacroExecution::finished); + } + + public boolean running(Macro macro) { + for (MacroExecution execution : executions) { + if (execution.macro() == macro && !execution.finished()) { + return true; + } + } + return false; + } + + public void start(Macro macro, Minecraft client, TickScheduler scheduler) { + if (running(macro)) { + return; + } + LocalPlayer player = client.player; + int startSlot = player != null ? player.getInventory().getSelectedSlot() : -1; + MacroExecution execution = new MacroExecution(macro, new ActionContext(client, scheduler, startSlot)); + execution.run(); + if (!execution.finished()) { + executions.add(execution); + } + } +} diff --git a/src/main/java/com/example/keybinds/core/exec/MacroExecution.java b/src/main/java/com/example/keybinds/core/exec/MacroExecution.java new file mode 100644 index 0000000..f48226b --- /dev/null +++ b/src/main/java/com/example/keybinds/core/exec/MacroExecution.java @@ -0,0 +1,58 @@ +package com.example.keybinds.core.exec; + +import com.example.keybinds.core.action.Action; +import com.example.keybinds.core.action.ActionContext; +import com.example.keybinds.core.action.ActionResult; +import com.example.keybinds.core.macro.Macro; + +import java.util.List; + +public final class MacroExecution { + + private final Macro macro; + private final List actions; + private final ActionContext context; + + private int index; + private int delay; + private boolean aborted; + + public MacroExecution(Macro macro, ActionContext context) { + this.macro = macro; + this.actions = List.copyOf(macro.actions()); + this.context = context; + } + + public Macro macro() { + return macro; + } + + public boolean finished() { + return aborted || index >= actions.size(); + } + + public void run() { + while (!finished()) { + ActionResult result = actions.get(index).execute(context); + if (result.aborted()) { + aborted = true; + return; + } + index++; + if (result.delayTicks() > 0) { + delay = result.delayTicks(); + return; + } + } + } + + public void tick() { + if (finished() || delay <= 0) { + return; + } + delay--; + if (delay <= 0) { + run(); + } + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java b/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java index be4a48d..6cf1c31 100644 --- a/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java @@ -20,4 +20,16 @@ public void write(JsonObject out) { } out.add("children", array); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = true; + boolean childFired = false; + for (Trigger child : children) { + TriggerResult result = child.evaluate(ctx); + active &= result.active(); + childFired |= result.fired(); + } + return new TriggerResult(active, active && childFired); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java b/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java index 613ce3a..8d8140a 100644 --- a/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java @@ -20,4 +20,16 @@ public void write(JsonObject out) { } out.add("children", array); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = false; + boolean fired = false; + for (Trigger child : children) { + TriggerResult result = child.evaluate(ctx); + active |= result.active(); + fired |= result.fired(); + } + return new TriggerResult(active, fired); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java b/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java index f464c11..63d0093 100644 --- a/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java @@ -13,4 +13,10 @@ public TriggerType type() { public void write(JsonObject out) { out.addProperty("condition", condition); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = ctx.conditionActive(condition); + return new TriggerResult(active, ctx.edge(this, active)); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java b/src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java new file mode 100644 index 0000000..3fbadc5 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java @@ -0,0 +1,28 @@ +package com.example.keybinds.core.trigger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; + +public record HoldingTrigger(List items) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.HOLDING; + } + + @Override + public void write(JsonObject out) { + JsonArray array = new JsonArray(); + for (String item : items) { + array.add(item); + } + out.add("items", array); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + return new TriggerResult(ctx.holdingAny(items), false); + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java b/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java index ff8ab1e..fa7be1c 100644 --- a/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java @@ -13,4 +13,10 @@ public TriggerType type() { public void write(JsonObject out) { out.addProperty("key", key); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = ctx.keyDown(key); + return new TriggerResult(active, ctx.edge(this, active)); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java b/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java index 40c801a..9a9d805 100644 --- a/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java @@ -13,4 +13,10 @@ public TriggerType type() { public void write(JsonObject out) { out.addProperty("button", button); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = ctx.mouseDown(button); + return new TriggerResult(active, ctx.edge(this, active)); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java b/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java index daf1751..9e968bb 100644 --- a/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java @@ -13,4 +13,10 @@ public TriggerType type() { public void write(JsonObject out) { out.add("child", TriggerRegistry.toJson(child)); } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + TriggerResult result = child.evaluate(ctx); + return new TriggerResult(!result.active(), false); + } } diff --git a/src/main/java/com/example/keybinds/core/trigger/Trigger.java b/src/main/java/com/example/keybinds/core/trigger/Trigger.java index 05d5235..7b7400f 100644 --- a/src/main/java/com/example/keybinds/core/trigger/Trigger.java +++ b/src/main/java/com/example/keybinds/core/trigger/Trigger.java @@ -7,4 +7,6 @@ public interface Trigger { TriggerType type(); void write(JsonObject out); + + TriggerResult evaluate(TriggerContext ctx); } diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerContext.java b/src/main/java/com/example/keybinds/core/trigger/TriggerContext.java new file mode 100644 index 0000000..c4f8cdc --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerContext.java @@ -0,0 +1,65 @@ +package com.example.keybinds.core.trigger; + +import com.example.keybinds.core.condition.PlayerConditions; +import com.example.keybinds.game.ItemLookup; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.Minecraft; +import org.lwjgl.glfw.GLFW; + +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +public final class TriggerContext { + + private final Map previous = new IdentityHashMap<>(); + private final Map current = new IdentityHashMap<>(); + private boolean resync = true; + private Minecraft client; + + public void begin(Minecraft client) { + this.client = client; + } + + public boolean keyDown(int key) { + return key >= 0 && client != null && InputConstants.isKeyDown(client.getWindow(), key); + } + + public boolean mouseDown(int button) { + return button >= 0 && client != null + && GLFW.glfwGetMouseButton(client.getWindow().handle(), button) == GLFW.GLFW_PRESS; + } + + public boolean conditionActive(String id) { + return client != null && PlayerConditions.active(id, client.player); + } + + public boolean holdingAny(List items) { + if (items == null || items.isEmpty()) { + return true; + } + if (client == null || client.player == null) { + return false; + } + return ItemLookup.matchesAny(client.player.getMainHandItem(), items); + } + + public boolean edge(Trigger node, boolean active) { + current.put(node, active); + boolean was = previous.getOrDefault(node, false); + return active && !was && !resync; + } + + public void commit() { + previous.clear(); + previous.putAll(current); + current.clear(); + resync = false; + } + + public void reset() { + previous.clear(); + current.clear(); + resync = true; + } +} diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerResult.java b/src/main/java/com/example/keybinds/core/trigger/TriggerResult.java new file mode 100644 index 0000000..8b1eec8 --- /dev/null +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerResult.java @@ -0,0 +1,4 @@ +package com.example.keybinds.core.trigger; + +public record TriggerResult(boolean active, boolean fired) { +} diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java index d311cae..9f5d1a8 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java +++ b/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java @@ -33,12 +33,25 @@ public final class TriggerTypes { return child == null ? null : new NotTrigger(child); })); + public static final TriggerType HOLDING = TriggerRegistry.register(new TriggerType("holding", + json -> new HoldingTrigger(readStrings(json)))); + private TriggerTypes() { } public static void bootstrap() { } + private static List readStrings(JsonObject json) { + List values = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(json, "items", new JsonArray())) { + if (element.isJsonPrimitive()) { + values.add(element.getAsString()); + } + } + return values; + } + private static List readChildren(JsonObject json) { List children = new ArrayList<>(); for (JsonElement element : GsonHelper.getAsJsonArray(json, "children", new JsonArray())) { diff --git a/src/main/java/com/example/keybinds/game/ItemLookup.java b/src/main/java/com/example/keybinds/game/ItemLookup.java new file mode 100644 index 0000000..50ec444 --- /dev/null +++ b/src/main/java/com/example/keybinds/game/ItemLookup.java @@ -0,0 +1,84 @@ +package com.example.keybinds.game; + +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.TagKey; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.enchantment.ItemEnchantments; + +import java.util.List; + +public final class ItemLookup { + + private ItemLookup() { + } + + public static Item resolveItem(String id) { + if (id == null || id.isBlank()) { + return null; + } + Identifier rl = Identifier.tryParse(id.trim()); + if (rl == null) { + return null; + } + return BuiltInRegistries.ITEM.getOptional(rl).orElse(null); + } + + public static TagKey resolveTag(String id) { + if (id == null || id.isBlank()) { + return null; + } + Identifier rl = Identifier.tryParse(id.trim()); + return rl == null ? null : TagKey.create(Registries.ITEM, rl); + } + + public static boolean matchesAny(ItemStack stack, List ids) { + if (ids == null) { + return false; + } + for (String id : ids) { + Item item = resolveItem(id); + if (item != null && stack.is(item)) { + return true; + } + } + return false; + } + + public static int enchantLevel(ItemStack stack, String enchantId) { + if (stack.isEmpty() || enchantId == null || enchantId.isBlank()) { + return 0; + } + Identifier target = Identifier.tryParse(enchantId.trim()); + if (target == null) { + return 0; + } + ItemEnchantments enchantments = stack.getEnchantments(); + for (var entry : enchantments.entrySet()) { + if (entry.getKey().is(target)) { + return entry.getIntValue(); + } + } + return 0; + } + + public static int findHotbarSlot(Inventory inventory, List items, String tag) { + TagKey tagKey = resolveTag(tag); + for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { + ItemStack stack = inventory.getItem(slot); + if (stack.isEmpty()) { + continue; + } + if (tagKey != null && stack.is(tagKey)) { + return slot; + } + if (matchesAny(stack, items)) { + return slot; + } + } + return -1; + } +} From 21a780719d3d063c464f51513115df9fd1d42b80 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 11:07:06 -0500 Subject: [PATCH 05/19] match legacy vanilla-mode timing in compiled macros and drop dead code --- .../java/com/example/keybinds/compat/ConfigMacros.java | 8 +++++--- .../example/keybinds/core/schedule/TickScheduler.java | 4 ---- .../java/com/example/keybinds/game/PlayerActions.java | 10 ---------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/example/keybinds/compat/ConfigMacros.java b/src/main/java/com/example/keybinds/compat/ConfigMacros.java index 7a59fdb..ca3126e 100644 --- a/src/main/java/com/example/keybinds/compat/ConfigMacros.java +++ b/src/main/java/com/example/keybinds/compat/ConfigMacros.java @@ -29,6 +29,8 @@ public final class ConfigMacros { + private static final int LEGACY_SCHEDULER_LATENCY = 1; + private ConfigMacros() { } @@ -97,7 +99,7 @@ private static Macro shieldTotemMacro(KeybindsConfig config) { } List actions = new ArrayList<>(); if (config.shieldTotemVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay)); + actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); } actions.add(new ShieldTotemSwapAction()); return new Macro(UUID.randomUUID(), "Shield/totem swap", true, @@ -115,7 +117,7 @@ private static Macro maceStunSlamMacro(KeybindsConfig config) { actions.add(new RequireItemAction(List.of(), axesTag)); actions.add(new RequireItemAction(mace, "")); if (config.maceStunSlamVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay)); + actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); actions.add(new SelectItemAction(List.of(), axesTag)); actions.add(new AttackAction(false)); actions.add(new WaitAction(config.combatUseDelay)); @@ -161,7 +163,7 @@ private static Macro lungeSwapMacro(KeybindsConfig config) { List spears = config.spearItems == null ? List.of() : List.copyOf(config.spearItems); List actions = new ArrayList<>(); if (config.lungeSwapVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay)); + actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); actions.add(new SwapToItemAction(spears, config.lungeEnchant)); actions.add(new WaitAction(config.combatUseDelay)); actions.add(new AttackAction(true)); diff --git a/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java b/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java index be1c2a4..2edc9a5 100644 --- a/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java +++ b/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java @@ -37,8 +37,4 @@ public void tick() { } } } - - public boolean isIdle() { - return tasks.isEmpty(); - } } diff --git a/src/main/java/com/example/keybinds/game/PlayerActions.java b/src/main/java/com/example/keybinds/game/PlayerActions.java index 369b620..f1d472c 100644 --- a/src/main/java/com/example/keybinds/game/PlayerActions.java +++ b/src/main/java/com/example/keybinds/game/PlayerActions.java @@ -4,7 +4,6 @@ import net.minecraft.client.multiplayer.MultiPlayerGameMode; import net.minecraft.client.player.LocalPlayer; import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket; -import net.minecraft.tags.TagKey; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.Entity; @@ -156,15 +155,6 @@ public static int findHotbarSlot(Inventory inventory, Item item) { return -1; } - public static int findHotbarSlot(Inventory inventory, TagKey tag) { - for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) { - if (inventory.getItem(slot).is(tag)) { - return slot; - } - } - return -1; - } - public static int findItem(Inventory inventory, Item item) { for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) { if (inventory.getItem(slot).is(item)) { From 9e41797bcc1c9f48c0027882457cf7cdf7cbd005 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 11:18:24 -0500 Subject: [PATCH 06/19] rename example package, add macro manager screen, finish mod menu integration and metadata --- README.md | 8 +- build.gradle | 25 +--- gradle.properties | 2 +- src/main/java/com/example/Keybinds.java | 30 ---- .../java/com/example/ModMenuIntegration.java | 17 --- .../keybinds}/BindEditScreen.java | 2 +- .../java/com/kingjayan/keybinds/Keybinds.java | 23 ++++ .../keybinds}/KeybindsClient.java | 37 +++-- .../keybinds}/KeybindsConfig.java | 6 +- .../keybinds}/KeybindsConfigScreen.java | 2 +- .../keybinds}/LungeSwapScreen.java | 2 +- .../keybinds/ModMenuIntegration.java | 18 +++ .../keybinds/compat/ConfigMacros.java | 44 +++--- .../keybinds/core/action/Action.java | 2 +- .../keybinds/core/action/ActionContext.java | 4 +- .../keybinds/core/action/ActionRegistry.java | 2 +- .../keybinds/core/action/ActionResult.java | 2 +- .../keybinds/core/action/ActionType.java | 2 +- .../keybinds/core/action/ActionTypes.java | 2 +- .../keybinds/core/action/AttackAction.java | 4 +- .../core/action/RequireItemAction.java | 4 +- .../core/action/RestoreSlotAction.java | 4 +- .../core/action/SelectItemAction.java | 6 +- .../core/action/SelectSlotAction.java | 4 +- .../core/action/ShieldTotemSwapAction.java | 4 +- .../keybinds/core/action/SwapBackAction.java | 2 +- .../core/action/SwapToItemAction.java | 6 +- .../keybinds/core/action/UseAction.java | 4 +- .../keybinds/core/action/UseItemAction.java | 8 +- .../keybinds/core/action/WaitAction.java | 2 +- .../core/condition/PlayerConditions.java | 2 +- .../keybinds/core/exec/ExecutionEngine.java | 8 +- .../keybinds/core/exec/MacroExecution.java | 10 +- .../keybinds/core/input/EdgeDetector.java | 2 +- .../keybinds/core/macro/Macro.java | 6 +- .../keybinds/core/macro/MacroRegistry.java | 2 +- .../keybinds/core/schedule/TickScheduler.java | 2 +- .../keybinds/core/trigger/AllOfTrigger.java | 2 +- .../keybinds/core/trigger/AnyOfTrigger.java | 2 +- .../core/trigger/ConditionTrigger.java | 2 +- .../keybinds/core/trigger/HoldingTrigger.java | 2 +- .../keybinds/core/trigger/KeyTrigger.java | 2 +- .../core/trigger/MouseButtonTrigger.java | 2 +- .../keybinds/core/trigger/NotTrigger.java | 2 +- .../keybinds/core/trigger/Trigger.java | 2 +- .../keybinds/core/trigger/TriggerContext.java | 6 +- .../core/trigger/TriggerRegistry.java | 2 +- .../keybinds/core/trigger/TriggerResult.java | 2 +- .../keybinds/core/trigger/TriggerType.java | 2 +- .../keybinds/core/trigger/TriggerTypes.java | 2 +- .../keybinds/game/ItemLookup.java | 2 +- .../keybinds/game/PlayerActions.java | 2 +- .../keybinds/persist/MacroStorage.java | 6 +- .../keybinds/serialize/MacroCodec.java | 12 +- .../keybinds/ui/MacroManagerScreen.java | 128 ++++++++++++++++++ .../resources/assets/keybinds/lang/en_us.json | 5 + src/main/resources/fabric.mod.json | 17 +-- 57 files changed, 315 insertions(+), 197 deletions(-) delete mode 100644 src/main/java/com/example/Keybinds.java delete mode 100644 src/main/java/com/example/ModMenuIntegration.java rename src/main/java/com/{example => kingjayan/keybinds}/BindEditScreen.java (99%) create mode 100644 src/main/java/com/kingjayan/keybinds/Keybinds.java rename src/main/java/com/{example => kingjayan/keybinds}/KeybindsClient.java (79%) rename src/main/java/com/{example => kingjayan/keybinds}/KeybindsConfig.java (98%) rename src/main/java/com/{example => kingjayan/keybinds}/KeybindsConfigScreen.java (99%) rename src/main/java/com/{example => kingjayan/keybinds}/LungeSwapScreen.java (99%) create mode 100644 src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java rename src/main/java/com/{example => kingjayan}/keybinds/compat/ConfigMacros.java (83%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/Action.java (78%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ActionContext.java (88%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ActionRegistry.java (96%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ActionResult.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ActionType.java (77%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ActionTypes.java (98%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/AttackAction.java (84%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/RequireItemAction.java (90%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/RestoreSlotAction.java (81%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/SelectItemAction.java (88%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/SelectSlotAction.java (81%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/ShieldTotemSwapAction.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/SwapBackAction.java (89%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/SwapToItemAction.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/UseAction.java (81%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/UseItemAction.java (95%) rename src/main/java/com/{example => kingjayan}/keybinds/core/action/WaitAction.java (88%) rename src/main/java/com/{example => kingjayan}/keybinds/core/condition/PlayerConditions.java (96%) rename src/main/java/com/{example => kingjayan}/keybinds/core/exec/ExecutionEngine.java (83%) rename src/main/java/com/{example => kingjayan}/keybinds/core/exec/MacroExecution.java (78%) rename src/main/java/com/{example => kingjayan}/keybinds/core/input/EdgeDetector.java (93%) rename src/main/java/com/{example => kingjayan}/keybinds/core/macro/Macro.java (87%) rename src/main/java/com/{example => kingjayan}/keybinds/core/macro/MacroRegistry.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/schedule/TickScheduler.java (93%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/AllOfTrigger.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/AnyOfTrigger.java (94%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/ConditionTrigger.java (91%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/HoldingTrigger.java (92%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/KeyTrigger.java (90%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/MouseButtonTrigger.java (90%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/NotTrigger.java (90%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/Trigger.java (78%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/TriggerContext.java (90%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/TriggerRegistry.java (96%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/TriggerResult.java (58%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/TriggerType.java (77%) rename src/main/java/com/{example => kingjayan}/keybinds/core/trigger/TriggerTypes.java (98%) rename src/main/java/com/{example => kingjayan}/keybinds/game/ItemLookup.java (98%) rename src/main/java/com/{example => kingjayan}/keybinds/game/PlayerActions.java (99%) rename src/main/java/com/{example => kingjayan}/keybinds/persist/MacroStorage.java (91%) rename src/main/java/com/{example => kingjayan}/keybinds/serialize/MacroCodec.java (89%) create mode 100644 src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java diff --git a/README.md b/README.md index 147fa2e..c0df724 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # keybinds -A **fully client-side** Fabric mod for Minecraft **26.2** built around configurable **Use Item** binds and combat helpers. +A **fully client-side** Fabric mod for Minecraft **26.2**: a tick-based macro engine driving configurable **Use Item** binds and combat helpers. + +All features run on a shared macro engine with composable triggers (key, mouse, held-item, conditions, AND/OR/NOT) and tick-scheduled actions. Custom macros are stored in `config/macros.json`; press **Right Shift** in-game (or use the Mod Menu config button) to open the macro manager, which also links to the settings screen for the built-in helpers below. Each **Use Item** bind right-clicks any item you choose (anything with a right-click action) when one or more configurable triggers activate. Every bind can either **tap** once or **hold** right-click until released. @@ -50,7 +52,7 @@ Everything uses vanilla item-use and inventory packets, so **no server-side mod ## Dependencies -Configuration is done through the Mod Menu config screen. +Configuration is done in-game — Right Shift opens the macro manager, and Mod Menu's config button opens the same screen. - [Mod Menu v20.0.0+](https://modrinth.com/mod/modmenu) - [Fabric API v0.154.0+26.2](https://modrinth.com/mod/fabric-api) @@ -72,7 +74,7 @@ Items that target blocks (placing blocks, buckets, flint & steel, etc.) still re ## Configuration -Settings are stored in `config/keybinds.json` (created on first launch). Editing it manually is optional—the Mod Menu UI is recommended. +Helper settings are stored in `config/keybinds.json` and custom macros in `config/macros.json` (created on first launch). Editing them manually is optional—the in-game UI is recommended. ```json { diff --git a/build.gradle b/build.gradle index 545cb41..4a122d9 100644 --- a/build.gradle +++ b/build.gradle @@ -7,12 +7,6 @@ version = project.mod_version group = project.maven_group repositories { - // Add repositories to retrieve artifacts from in here. - // You should only use this when depending on other mods because - // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. - // See https://docs.gradle.org/current/userguide/declaring_repositories.html - // for more information about repositories. - // Mod Menu is distributed through Modrinth's maven. exclusiveContent { forRepository { @@ -25,17 +19,13 @@ repositories { } dependencies { - // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" implementation "net.fabricmc:fabric-loader:${project.loader_version}" - - // Fabric API. This is technically optional, but you probably want it anyway. implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" // Mod Menu — provides the in-game config button. Optional at runtime (the modmenu // entrypoint only loads when it is present), but needed on the compile classpath. - // 26.2 ships official mappings, so no remap configuration is needed — plain implementation - // puts the (already officially-mapped) jar on the classpath and Fabric loads it in dev. + // 26.2 ships official mappings, so no remap configuration is needed. implementation "maven.modrinth:modmenu:${project.modmenu_version}" } @@ -53,11 +43,7 @@ tasks.withType(JavaCompile).configureEach { } java { - // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task - // if it is present. - // If you remove this line, sources will not be generated. withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_25 targetCompatibility = JavaVersion.VERSION_25 } @@ -71,19 +57,10 @@ jar { } } -// configure the maven publication publishing { publications { create("mavenJava", MavenPublication) { from components.java } } - - // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing. - repositories { - // Add repositories to publish to here. - // Notice: This block does NOT have the same function as the block in the top level. - // The repositories here will be used for publishing your artifact, not for - // retrieving dependencies. - } } diff --git a/gradle.properties b/gradle.properties index 9c55159..dfa120c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ loom_version=1.17-SNAPSHOT # Mod Properties mod_version=1.1.0 -maven_group=com.example +maven_group=com.kingjayan # Dependencies fabric_api_version=0.154.0+26.2 diff --git a/src/main/java/com/example/Keybinds.java b/src/main/java/com/example/Keybinds.java deleted file mode 100644 index d7ea16a..0000000 --- a/src/main/java/com/example/Keybinds.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example; - -import net.fabricmc.api.ModInitializer; - -import net.minecraft.resources.Identifier; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class Keybinds implements ModInitializer { - public static final String MOD_ID = "keybinds"; - - // This logger is used to write text to the console and the log file. - // It is considered best practice to use your mod id as the logger's name. - // That way, it's clear which mod wrote info, warnings, and errors. - public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); - - @Override - public void onInitialize() { - // This code runs as soon as Minecraft is in a mod-load-ready state. - // However, some things (like resources) may still be uninitialized. - // Proceed with mild caution. - - LOGGER.info("keybinds initialized (client-side convenience keybinds)"); - } - - public static Identifier id(String path) { - return Identifier.fromNamespaceAndPath(MOD_ID, path); - } -} diff --git a/src/main/java/com/example/ModMenuIntegration.java b/src/main/java/com/example/ModMenuIntegration.java deleted file mode 100644 index fcd9d48..0000000 --- a/src/main/java/com/example/ModMenuIntegration.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example; - -import com.terraformersmc.modmenu.api.ConfigScreenFactory; -import com.terraformersmc.modmenu.api.ModMenuApi; - -/** - * Mod Menu entrypoint. Wires the "config" button next to this mod in the Mods list to our - * {@link KeybindsConfigScreen}. This class is only loaded when Mod Menu is present, so Mod Menu is - * an optional dependency — the mod runs fine without it. - */ -public class ModMenuIntegration implements ModMenuApi { - - @Override - public ConfigScreenFactory getModConfigScreenFactory() { - return KeybindsConfigScreen::new; - } -} diff --git a/src/main/java/com/example/BindEditScreen.java b/src/main/java/com/kingjayan/keybinds/BindEditScreen.java similarity index 99% rename from src/main/java/com/example/BindEditScreen.java rename to src/main/java/com/kingjayan/keybinds/BindEditScreen.java index 2910d37..5e0bc11 100644 --- a/src/main/java/com/example/BindEditScreen.java +++ b/src/main/java/com/kingjayan/keybinds/BindEditScreen.java @@ -1,4 +1,4 @@ -package com.example; +package com.kingjayan.keybinds; import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.gui.components.AbstractWidget; diff --git a/src/main/java/com/kingjayan/keybinds/Keybinds.java b/src/main/java/com/kingjayan/keybinds/Keybinds.java new file mode 100644 index 0000000..2fdbb9c --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/Keybinds.java @@ -0,0 +1,23 @@ +package com.kingjayan.keybinds; + +import net.fabricmc.api.ModInitializer; + +import net.minecraft.resources.Identifier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Keybinds implements ModInitializer { + public static final String MOD_ID = "keybinds"; + + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + @Override + public void onInitialize() { + LOGGER.info("keybinds initialized (client-side macro framework)"); + } + + public static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(MOD_ID, path); + } +} diff --git a/src/main/java/com/example/KeybindsClient.java b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java similarity index 79% rename from src/main/java/com/example/KeybindsClient.java rename to src/main/java/com/kingjayan/keybinds/KeybindsClient.java index 2c59d04..297741f 100644 --- a/src/main/java/com/example/KeybindsClient.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java @@ -1,16 +1,17 @@ -package com.example; - -import com.example.keybinds.compat.ConfigMacros; -import com.example.keybinds.core.action.ActionTypes; -import com.example.keybinds.core.exec.ExecutionEngine; -import com.example.keybinds.core.input.EdgeDetector; -import com.example.keybinds.core.macro.Macro; -import com.example.keybinds.core.macro.MacroRegistry; -import com.example.keybinds.core.schedule.TickScheduler; -import com.example.keybinds.core.trigger.TriggerContext; -import com.example.keybinds.core.trigger.TriggerTypes; -import com.example.keybinds.game.PlayerActions; -import com.example.keybinds.persist.MacroStorage; +package com.kingjayan.keybinds; + +import com.kingjayan.keybinds.compat.ConfigMacros; +import com.kingjayan.keybinds.core.action.ActionTypes; +import com.kingjayan.keybinds.core.exec.ExecutionEngine; +import com.kingjayan.keybinds.core.input.EdgeDetector; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.macro.MacroRegistry; +import com.kingjayan.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.core.trigger.TriggerContext; +import com.kingjayan.keybinds.core.trigger.TriggerTypes; +import com.kingjayan.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.persist.MacroStorage; +import com.kingjayan.keybinds.ui.MacroManagerScreen; import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.platform.Window; import net.fabricmc.api.ClientModInitializer; @@ -19,6 +20,7 @@ import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.item.Item; +import org.lwjgl.glfw.GLFW; import java.util.List; @@ -59,6 +61,15 @@ public void onInitializeClient() { configMacros = ConfigMacros.build(config); } + if (holdEdges.justPressed(GLFW.GLFW_KEY_RIGHT_SHIFT, + InputConstants.isKeyDown(client.getWindow(), GLFW.GLFW_KEY_RIGHT_SHIFT)) + && !holdEdges.resyncing()) { + client.setScreenAndShow(new MacroManagerScreen(null)); + triggers.reset(); + holdEdges.reset(); + return; + } + triggers.begin(client); pollHolds(client, config); fireMacros(client, configMacros); diff --git a/src/main/java/com/example/KeybindsConfig.java b/src/main/java/com/kingjayan/keybinds/KeybindsConfig.java similarity index 98% rename from src/main/java/com/example/KeybindsConfig.java rename to src/main/java/com/kingjayan/keybinds/KeybindsConfig.java index 436af88..3e6fe18 100644 --- a/src/main/java/com/example/KeybindsConfig.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsConfig.java @@ -1,4 +1,4 @@ -package com.example; +package com.kingjayan.keybinds; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -61,7 +61,7 @@ public class KeybindsConfig { * tick; a bind with any of these listed fires when any of them flips from false to true * (edge-triggered, so it fires once per onset, not every tick while true). Thresholds are pre-set. */ - public static final List CONDITIONS = com.example.keybinds.core.condition.PlayerConditions.ids(); + public static final List CONDITIONS = com.kingjayan.keybinds.core.condition.PlayerConditions.ids(); /** * One user-defined bind. At its core it names an item to right-click ({@link #item}) and a tap/hold @@ -180,7 +180,7 @@ public static boolean isBound(int key) { /** Resolve an item id string to its {@link Item}, or {@code null} if it is blank or unknown. */ public static Item resolveItem(String id) { - return com.example.keybinds.game.ItemLookup.resolveItem(id); + return com.kingjayan.keybinds.game.ItemLookup.resolveItem(id); } // --- Persistence --------------------------------------------------------------------------- diff --git a/src/main/java/com/example/KeybindsConfigScreen.java b/src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java similarity index 99% rename from src/main/java/com/example/KeybindsConfigScreen.java rename to src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java index bb6dc5b..583ae20 100644 --- a/src/main/java/com/example/KeybindsConfigScreen.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java @@ -1,4 +1,4 @@ -package com.example; +package com.kingjayan.keybinds; import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.gui.components.Button; diff --git a/src/main/java/com/example/LungeSwapScreen.java b/src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java similarity index 99% rename from src/main/java/com/example/LungeSwapScreen.java rename to src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java index 67ad205..26c6d1b 100644 --- a/src/main/java/com/example/LungeSwapScreen.java +++ b/src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java @@ -1,4 +1,4 @@ -package com.example; +package com.kingjayan.keybinds; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.Button; diff --git a/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java new file mode 100644 index 0000000..b43941f --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java @@ -0,0 +1,18 @@ +package com.kingjayan.keybinds; + +import com.kingjayan.keybinds.ui.MacroManagerScreen; +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; + +/** + * Mod Menu entrypoint. Wires the "config" button next to this mod in the Mods list to the + * {@link MacroManagerScreen} — the same screen Right Shift opens in-game. This class is only loaded + * when Mod Menu is present, so Mod Menu is an optional dependency — the mod runs fine without it. + */ +public class ModMenuIntegration implements ModMenuApi { + + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + return MacroManagerScreen::new; + } +} diff --git a/src/main/java/com/example/keybinds/compat/ConfigMacros.java b/src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java similarity index 83% rename from src/main/java/com/example/keybinds/compat/ConfigMacros.java rename to src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java index ca3126e..27f2cfe 100644 --- a/src/main/java/com/example/keybinds/compat/ConfigMacros.java +++ b/src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java @@ -1,25 +1,25 @@ -package com.example.keybinds.compat; - -import com.example.KeybindsConfig; -import com.example.keybinds.core.action.Action; -import com.example.keybinds.core.action.AttackAction; -import com.example.keybinds.core.action.RequireItemAction; -import com.example.keybinds.core.action.RestoreSlotAction; -import com.example.keybinds.core.action.SelectItemAction; -import com.example.keybinds.core.action.ShieldTotemSwapAction; -import com.example.keybinds.core.action.SwapBackAction; -import com.example.keybinds.core.action.SwapToItemAction; -import com.example.keybinds.core.action.UseItemAction; -import com.example.keybinds.core.action.WaitAction; -import com.example.keybinds.core.macro.Macro; -import com.example.keybinds.core.trigger.AllOfTrigger; -import com.example.keybinds.core.trigger.AnyOfTrigger; -import com.example.keybinds.core.trigger.ConditionTrigger; -import com.example.keybinds.core.trigger.HoldingTrigger; -import com.example.keybinds.core.trigger.KeyTrigger; -import com.example.keybinds.core.trigger.MouseButtonTrigger; -import com.example.keybinds.core.trigger.NotTrigger; -import com.example.keybinds.core.trigger.Trigger; +package com.kingjayan.keybinds.compat; + +import com.kingjayan.keybinds.KeybindsConfig; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.AttackAction; +import com.kingjayan.keybinds.core.action.RequireItemAction; +import com.kingjayan.keybinds.core.action.RestoreSlotAction; +import com.kingjayan.keybinds.core.action.SelectItemAction; +import com.kingjayan.keybinds.core.action.ShieldTotemSwapAction; +import com.kingjayan.keybinds.core.action.SwapBackAction; +import com.kingjayan.keybinds.core.action.SwapToItemAction; +import com.kingjayan.keybinds.core.action.UseItemAction; +import com.kingjayan.keybinds.core.action.WaitAction; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.trigger.AllOfTrigger; +import com.kingjayan.keybinds.core.trigger.AnyOfTrigger; +import com.kingjayan.keybinds.core.trigger.ConditionTrigger; +import com.kingjayan.keybinds.core.trigger.HoldingTrigger; +import com.kingjayan.keybinds.core.trigger.KeyTrigger; +import com.kingjayan.keybinds.core.trigger.MouseButtonTrigger; +import com.kingjayan.keybinds.core.trigger.NotTrigger; +import com.kingjayan.keybinds.core.trigger.Trigger; import net.minecraft.tags.ItemTags; import org.lwjgl.glfw.GLFW; diff --git a/src/main/java/com/example/keybinds/core/action/Action.java b/src/main/java/com/kingjayan/keybinds/core/action/Action.java similarity index 78% rename from src/main/java/com/example/keybinds/core/action/Action.java rename to src/main/java/com/kingjayan/keybinds/core/action/Action.java index 46944e3..6297ce2 100644 --- a/src/main/java/com/example/keybinds/core/action/Action.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/Action.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/action/ActionContext.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java similarity index 88% rename from src/main/java/com/example/keybinds/core/action/ActionContext.java rename to src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java index 61bb5a9..8b9b82d 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionContext.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.core.schedule.TickScheduler; import net.minecraft.client.Minecraft; import java.util.ArrayDeque; diff --git a/src/main/java/com/example/keybinds/core/action/ActionRegistry.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java similarity index 96% rename from src/main/java/com/example/keybinds/core/action/ActionRegistry.java rename to src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java index 6b188c0..a068b33 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionRegistry.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonObject; import net.minecraft.util.GsonHelper; diff --git a/src/main/java/com/example/keybinds/core/action/ActionResult.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java similarity index 94% rename from src/main/java/com/example/keybinds/core/action/ActionResult.java rename to src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java index 076b6b0..1ed5203 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionResult.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; public final class ActionResult { diff --git a/src/main/java/com/example/keybinds/core/action/ActionType.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionType.java similarity index 77% rename from src/main/java/com/example/keybinds/core/action/ActionType.java rename to src/main/java/com/kingjayan/keybinds/core/action/ActionType.java index e07dff7..e71e7d8 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionType.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionType.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/action/ActionTypes.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java similarity index 98% rename from src/main/java/com/example/keybinds/core/action/ActionTypes.java rename to src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java index 49870e2..0c1ce46 100644 --- a/src/main/java/com/example/keybinds/core/action/ActionTypes.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonArray; import com.google.gson.JsonElement; diff --git a/src/main/java/com/example/keybinds/core/action/AttackAction.java b/src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java similarity index 84% rename from src/main/java/com/example/keybinds/core/action/AttackAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java index de56c32..04f7b37 100644 --- a/src/main/java/com/example/keybinds/core/action/AttackAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; public record AttackAction(boolean swing) implements Action { diff --git a/src/main/java/com/example/keybinds/core/action/RequireItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java similarity index 90% rename from src/main/java/com/example/keybinds/core/action/RequireItemAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java index 6a8ea38..cdf8d86 100644 --- a/src/main/java/com/example/keybinds/core/action/RequireItemAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.ItemLookup; +import com.kingjayan.keybinds.game.ItemLookup; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import net.minecraft.client.player.LocalPlayer; diff --git a/src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java similarity index 81% rename from src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java index bd473d8..32d1659 100644 --- a/src/main/java/com/example/keybinds/core/action/RestoreSlotAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; public record RestoreSlotAction() implements Action { diff --git a/src/main/java/com/example/keybinds/core/action/SelectItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java similarity index 88% rename from src/main/java/com/example/keybinds/core/action/SelectItemAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java index 02d3675..6d94bcc 100644 --- a/src/main/java/com/example/keybinds/core/action/SelectItemAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java @@ -1,7 +1,7 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.ItemLookup; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.ItemLookup; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; diff --git a/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java similarity index 81% rename from src/main/java/com/example/keybinds/core/action/SelectSlotAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java index 0a2c976..0ccc5fe 100644 --- a/src/main/java/com/example/keybinds/core/action/SelectSlotAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; public record SelectSlotAction(int slot) implements Action { diff --git a/src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java b/src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java similarity index 94% rename from src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java index 6b61917..de83f6d 100644 --- a/src/main/java/com/example/keybinds/core/action/ShieldTotemSwapAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.MultiPlayerGameMode; diff --git a/src/main/java/com/example/keybinds/core/action/SwapBackAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java similarity index 89% rename from src/main/java/com/example/keybinds/core/action/SwapBackAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java index 655300f..e1ba808 100644 --- a/src/main/java/com/example/keybinds/core/action/SwapBackAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/action/SwapToItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java similarity index 94% rename from src/main/java/com/example/keybinds/core/action/SwapToItemAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java index df3c91f..19f9212 100644 --- a/src/main/java/com/example/keybinds/core/action/SwapToItemAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java @@ -1,7 +1,7 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.ItemLookup; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.ItemLookup; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; diff --git a/src/main/java/com/example/keybinds/core/action/UseAction.java b/src/main/java/com/kingjayan/keybinds/core/action/UseAction.java similarity index 81% rename from src/main/java/com/example/keybinds/core/action/UseAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/UseAction.java index 6eef64a..bd75805 100644 --- a/src/main/java/com/example/keybinds/core/action/UseAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/UseAction.java @@ -1,6 +1,6 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; import net.minecraft.world.InteractionHand; diff --git a/src/main/java/com/example/keybinds/core/action/UseItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java similarity index 95% rename from src/main/java/com/example/keybinds/core/action/UseItemAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java index d9d976f..644e1cf 100644 --- a/src/main/java/com/example/keybinds/core/action/UseItemAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java @@ -1,8 +1,8 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; -import com.example.keybinds.core.schedule.TickScheduler; -import com.example.keybinds.game.ItemLookup; -import com.example.keybinds.game.PlayerActions; +import com.kingjayan.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.game.ItemLookup; +import com.kingjayan.keybinds.game.PlayerActions; import com.google.gson.JsonObject; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; diff --git a/src/main/java/com/example/keybinds/core/action/WaitAction.java b/src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java similarity index 88% rename from src/main/java/com/example/keybinds/core/action/WaitAction.java rename to src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java index 6d65450..e709ff3 100644 --- a/src/main/java/com/example/keybinds/core/action/WaitAction.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.action; +package com.kingjayan.keybinds.core.action; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/condition/PlayerConditions.java b/src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java similarity index 96% rename from src/main/java/com/example/keybinds/core/condition/PlayerConditions.java rename to src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java index 4835877..2ab7ea6 100644 --- a/src/main/java/com/example/keybinds/core/condition/PlayerConditions.java +++ b/src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.condition; +package com.kingjayan.keybinds.core.condition; import net.minecraft.client.player.LocalPlayer; diff --git a/src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java b/src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java similarity index 83% rename from src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java rename to src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java index 777b848..a58713f 100644 --- a/src/main/java/com/example/keybinds/core/exec/ExecutionEngine.java +++ b/src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java @@ -1,8 +1,8 @@ -package com.example.keybinds.core.exec; +package com.kingjayan.keybinds.core.exec; -import com.example.keybinds.core.action.ActionContext; -import com.example.keybinds.core.macro.Macro; -import com.example.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.core.action.ActionContext; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.schedule.TickScheduler; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; diff --git a/src/main/java/com/example/keybinds/core/exec/MacroExecution.java b/src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java similarity index 78% rename from src/main/java/com/example/keybinds/core/exec/MacroExecution.java rename to src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java index f48226b..ed6052f 100644 --- a/src/main/java/com/example/keybinds/core/exec/MacroExecution.java +++ b/src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java @@ -1,9 +1,9 @@ -package com.example.keybinds.core.exec; +package com.kingjayan.keybinds.core.exec; -import com.example.keybinds.core.action.Action; -import com.example.keybinds.core.action.ActionContext; -import com.example.keybinds.core.action.ActionResult; -import com.example.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.ActionContext; +import com.kingjayan.keybinds.core.action.ActionResult; +import com.kingjayan.keybinds.core.macro.Macro; import java.util.List; diff --git a/src/main/java/com/example/keybinds/core/input/EdgeDetector.java b/src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java similarity index 93% rename from src/main/java/com/example/keybinds/core/input/EdgeDetector.java rename to src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java index bb5dc44..6dbed8b 100644 --- a/src/main/java/com/example/keybinds/core/input/EdgeDetector.java +++ b/src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.input; +package com.kingjayan.keybinds.core.input; import java.util.HashSet; import java.util.Set; diff --git a/src/main/java/com/example/keybinds/core/macro/Macro.java b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java similarity index 87% rename from src/main/java/com/example/keybinds/core/macro/Macro.java rename to src/main/java/com/kingjayan/keybinds/core/macro/Macro.java index 5a282b4..4d0d46e 100644 --- a/src/main/java/com/example/keybinds/core/macro/Macro.java +++ b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java @@ -1,7 +1,7 @@ -package com.example.keybinds.core.macro; +package com.kingjayan.keybinds.core.macro; -import com.example.keybinds.core.action.Action; -import com.example.keybinds.core.trigger.Trigger; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.trigger.Trigger; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/example/keybinds/core/macro/MacroRegistry.java b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java similarity index 94% rename from src/main/java/com/example/keybinds/core/macro/MacroRegistry.java rename to src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java index 03263ba..41cc94f 100644 --- a/src/main/java/com/example/keybinds/core/macro/MacroRegistry.java +++ b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.macro; +package com.kingjayan.keybinds.core.macro; import java.util.ArrayList; import java.util.Collections; diff --git a/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java b/src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java similarity index 93% rename from src/main/java/com/example/keybinds/core/schedule/TickScheduler.java rename to src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java index 2edc9a5..d6e5556 100644 --- a/src/main/java/com/example/keybinds/core/schedule/TickScheduler.java +++ b/src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.schedule; +package com.kingjayan.keybinds.core.schedule; import java.util.ArrayList; import java.util.Iterator; diff --git a/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java similarity index 94% rename from src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java index 6cf1c31..51749f3 100644 --- a/src/main/java/com/example/keybinds/core/trigger/AllOfTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java similarity index 94% rename from src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java index 8d8140a..24aa5c7 100644 --- a/src/main/java/com/example/keybinds/core/trigger/AnyOfTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java similarity index 91% rename from src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java index 63d0093..1f30406 100644 --- a/src/main/java/com/example/keybinds/core/trigger/ConditionTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java similarity index 92% rename from src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java index 3fbadc5..2ab983c 100644 --- a/src/main/java/com/example/keybinds/core/trigger/HoldingTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonArray; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java similarity index 90% rename from src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java index fa7be1c..2f3dee1 100644 --- a/src/main/java/com/example/keybinds/core/trigger/KeyTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java similarity index 90% rename from src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java index 9a9d805..903677c 100644 --- a/src/main/java/com/example/keybinds/core/trigger/MouseButtonTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java similarity index 90% rename from src/main/java/com/example/keybinds/core/trigger/NotTrigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java index 9e968bb..9db80f7 100644 --- a/src/main/java/com/example/keybinds/core/trigger/NotTrigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/Trigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java similarity index 78% rename from src/main/java/com/example/keybinds/core/trigger/Trigger.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java index 7b7400f..53f549b 100644 --- a/src/main/java/com/example/keybinds/core/trigger/Trigger.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerContext.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java similarity index 90% rename from src/main/java/com/example/keybinds/core/trigger/TriggerContext.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java index c4f8cdc..5182b29 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerContext.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java @@ -1,7 +1,7 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; -import com.example.keybinds.core.condition.PlayerConditions; -import com.example.keybinds.game.ItemLookup; +import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.game.ItemLookup; import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.Minecraft; import org.lwjgl.glfw.GLFW; diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java similarity index 96% rename from src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java index cb6d9cf..6315281 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerRegistry.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; import net.minecraft.util.GsonHelper; diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerResult.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java similarity index 58% rename from src/main/java/com/example/keybinds/core/trigger/TriggerResult.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java index 8b1eec8..54d04d9 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerResult.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; public record TriggerResult(boolean active, boolean fired) { } diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerType.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java similarity index 77% rename from src/main/java/com/example/keybinds/core/trigger/TriggerType.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java index ed125b2..02c821c 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerType.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java similarity index 98% rename from src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java rename to src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java index 9f5d1a8..38da5ec 100644 --- a/src/main/java/com/example/keybinds/core/trigger/TriggerTypes.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java @@ -1,4 +1,4 @@ -package com.example.keybinds.core.trigger; +package com.kingjayan.keybinds.core.trigger; import com.google.gson.JsonArray; import com.google.gson.JsonElement; diff --git a/src/main/java/com/example/keybinds/game/ItemLookup.java b/src/main/java/com/kingjayan/keybinds/game/ItemLookup.java similarity index 98% rename from src/main/java/com/example/keybinds/game/ItemLookup.java rename to src/main/java/com/kingjayan/keybinds/game/ItemLookup.java index 50ec444..e95515a 100644 --- a/src/main/java/com/example/keybinds/game/ItemLookup.java +++ b/src/main/java/com/kingjayan/keybinds/game/ItemLookup.java @@ -1,4 +1,4 @@ -package com.example.keybinds.game; +package com.kingjayan.keybinds.game; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; diff --git a/src/main/java/com/example/keybinds/game/PlayerActions.java b/src/main/java/com/kingjayan/keybinds/game/PlayerActions.java similarity index 99% rename from src/main/java/com/example/keybinds/game/PlayerActions.java rename to src/main/java/com/kingjayan/keybinds/game/PlayerActions.java index f1d472c..a35b293 100644 --- a/src/main/java/com/example/keybinds/game/PlayerActions.java +++ b/src/main/java/com/kingjayan/keybinds/game/PlayerActions.java @@ -1,4 +1,4 @@ -package com.example.keybinds.game; +package com.kingjayan.keybinds.game; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.MultiPlayerGameMode; diff --git a/src/main/java/com/example/keybinds/persist/MacroStorage.java b/src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java similarity index 91% rename from src/main/java/com/example/keybinds/persist/MacroStorage.java rename to src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java index 47c23d8..6446867 100644 --- a/src/main/java/com/example/keybinds/persist/MacroStorage.java +++ b/src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java @@ -1,7 +1,7 @@ -package com.example.keybinds.persist; +package com.kingjayan.keybinds.persist; -import com.example.keybinds.core.macro.Macro; -import com.example.keybinds.serialize.MacroCodec; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.serialize.MacroCodec; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; diff --git a/src/main/java/com/example/keybinds/serialize/MacroCodec.java b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java similarity index 89% rename from src/main/java/com/example/keybinds/serialize/MacroCodec.java rename to src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java index 45ab356..0eea231 100644 --- a/src/main/java/com/example/keybinds/serialize/MacroCodec.java +++ b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java @@ -1,10 +1,10 @@ -package com.example.keybinds.serialize; +package com.kingjayan.keybinds.serialize; -import com.example.keybinds.core.action.Action; -import com.example.keybinds.core.action.ActionRegistry; -import com.example.keybinds.core.macro.Macro; -import com.example.keybinds.core.trigger.Trigger; -import com.example.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.trigger.Trigger; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; diff --git a/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java b/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java new file mode 100644 index 0000000..7de3045 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java @@ -0,0 +1,128 @@ +package com.kingjayan.keybinds.ui; + +import com.kingjayan.keybinds.KeybindsConfigScreen; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.macro.MacroRegistry; +import com.kingjayan.keybinds.persist.MacroStorage; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.List; + +public class MacroManagerScreen extends Screen { + + private static final int COLUMN_WIDTH = 320; + private static final int ROW_HEIGHT = 24; + private static final int ROWS_PER_PAGE = 7; + + private static final int NAME_WIDTH = 200; + private static final int TOGGLE_WIDTH = 68; + private static final int REMOVE_WIDTH = 20; + private static final int GAP = 4; + + private final Screen parent; + + private int page = 0; + + public MacroManagerScreen(Screen parent) { + super(Component.translatable("keybinds.macros.title")); + this.parent = parent; + } + + @Override + protected void init() { + int left = this.width / 2 - COLUMN_WIDTH / 2; + int y = 24; + + addRenderableWidget(new StringWidget(0, 8, this.width, 20, this.title, this.font)); + + List macros = MacroRegistry.get().all(); + if (macros.isEmpty()) { + addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, + Component.translatable("keybinds.macros.empty"), this.font)); + y += 16; + } else { + int totalPages = Math.max(1, (macros.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE); + page = Math.max(0, Math.min(page, totalPages - 1)); + + int first = page * ROWS_PER_PAGE; + int last = Math.min(first + ROWS_PER_PAGE, macros.size()); + for (int i = first; i < last; i++) { + addMacroRow(left, y, macros.get(i)); + y += ROW_HEIGHT; + } + + if (totalPages > 1) { + Button prev = Button.builder(Component.literal("◄"), b -> { + page--; + rebuildWidgets(); + }) + .bounds(left, y, 32, 20) + .build(); + prev.active = page > 0; + addRenderableWidget(prev); + + Button next = Button.builder(Component.literal("►"), b -> { + page++; + rebuildWidgets(); + }) + .bounds(left + 36, y, 32, 20) + .build(); + next.active = page < totalPages - 1; + addRenderableWidget(next); + y += ROW_HEIGHT; + } + } + + y += 8; + addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, + Component.translatable("keybinds.macros.note"), this.font)); + + addRenderableWidget(Button.builder(Component.translatable("keybinds.macros.settings"), + b -> openSettings()) + .bounds(this.width / 2 - 154, this.height - 28, 150, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(this.width / 2 + 4, this.height - 28, 150, 20) + .build()); + } + + private void addMacroRow(int left, int y, Macro macro) { + int x = left; + + addRenderableWidget(new StringWidget(x, y + 4, NAME_WIDTH, 12, + Component.literal(macro.name()), this.font)); + x += NAME_WIDTH + GAP; + + addRenderableWidget(CycleButton.onOffBuilder(macro.enabled()) + .displayOnlyValue() + .create(x, y, TOGGLE_WIDTH, 20, Component.empty(), + (button, value) -> macro.setEnabled(value))); + x += TOGGLE_WIDTH + GAP; + + addRenderableWidget(Button.builder(Component.literal("✕"), b -> { + MacroRegistry.get().remove(macro); + rebuildWidgets(); + }) + .bounds(x, y, REMOVE_WIDTH, 20) + .build()); + } + + private void openSettings() { + MacroStorage.save(MacroRegistry.get().all()); + if (this.minecraft != null) { + this.minecraft.setScreenAndShow(new KeybindsConfigScreen(this)); + } + } + + @Override + public void onClose() { + MacroStorage.save(MacroRegistry.get().all()); + if (this.minecraft != null) { + this.minecraft.setScreenAndShow(parent); + } + } +} diff --git a/src/main/resources/assets/keybinds/lang/en_us.json b/src/main/resources/assets/keybinds/lang/en_us.json index 041e2fc..6e8ebcd 100644 --- a/src/main/resources/assets/keybinds/lang/en_us.json +++ b/src/main/resources/assets/keybinds/lang/en_us.json @@ -1,4 +1,9 @@ { + "keybinds.macros.title": "Macros", + "keybinds.macros.empty": "No macros yet — custom macros live in config/macros.json.", + "keybinds.macros.note": "Toggle or delete macros here. Built-in helpers are configured under Settings.", + "keybinds.macros.settings": "Settings…", + "keybinds.config.title": "Keybinds Settings", "keybinds.config.uses_header": "Use Item binds — each right-clicks an item on its own trigger", "keybinds.config.uses_note": "Type an item (Tab to autocomplete), set a key, or open ⚙ for click/condition triggers & more.", diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 1c95fc8..f579ac1 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -2,27 +2,28 @@ "schemaVersion": 1, "id": "keybinds", "version": "${version}", - "name": "keybinds", - "description": "Client-side convenience keybinds: a user-defined list of numbered 'Use Item' keys that right-click (tap or hold) any item you pick — throwables, blocks, bows/crossbows, food — plus a shield/totem offhand swap and a two-tick axe+mace stun-slam. Configurable via Mod Menu.", + "name": "Keybinds", + "description": "Client-side macro framework: configurable Use Item binds (tap or hold), shield/totem offhand swap, mace stun-slam, lunge swap, and a tick-based macro engine with composable triggers. Manage macros in-game with Right Shift or via Mod Menu.", "authors": [ - "Me!" + "KingJayan" ], "contact": { - "homepage": "https://fabricmc.net/", - "sources": "https://github.com/FabricMC/fabric-example-mod" + "homepage": "https://github.com/KingJayan/keybinds", + "sources": "https://github.com/KingJayan/keybinds", + "issues": "https://github.com/KingJayan/keybinds/issues" }, "license": "CC0-1.0", "icon": "assets/keybinds/icon.png", "environment": "client", "entrypoints": { "main": [ - "com.example.Keybinds" + "com.kingjayan.keybinds.Keybinds" ], "client": [ - "com.example.KeybindsClient" + "com.kingjayan.keybinds.KeybindsClient" ], "modmenu": [ - "com.example.ModMenuIntegration" + "com.kingjayan.keybinds.ModMenuIntegration" ] }, "depends": { From 0494d73d579cb192300c9c00b6364f8e4c8a85d1 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 17:04:19 -0500 Subject: [PATCH 07/19] add trigger index and expand trigger catalog with release, slot, riding, and durability triggers --- .../kingjayan/keybinds/KeybindsClient.java | 13 +- .../keybinds/core/macro/MacroRegistry.java | 8 ++ .../core/trigger/HeldDurabilityTrigger.java | 22 ++++ .../core/trigger/KeyReleaseTrigger.java | 25 ++++ .../core/trigger/MouseReleaseTrigger.java | 25 ++++ .../keybinds/core/trigger/RidingTrigger.java | 21 ++++ .../core/trigger/SlotSelectedTrigger.java | 22 ++++ .../keybinds/core/trigger/TriggerContext.java | 18 +++ .../keybinds/core/trigger/TriggerIndex.java | 112 ++++++++++++++++++ .../keybinds/core/trigger/TriggerTypes.java | 15 +++ 10 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java index 297741f..e5209f1 100644 --- a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java @@ -8,6 +8,7 @@ import com.kingjayan.keybinds.core.macro.MacroRegistry; import com.kingjayan.keybinds.core.schedule.TickScheduler; import com.kingjayan.keybinds.core.trigger.TriggerContext; +import com.kingjayan.keybinds.core.trigger.TriggerIndex; import com.kingjayan.keybinds.core.trigger.TriggerTypes; import com.kingjayan.keybinds.game.PlayerActions; import com.kingjayan.keybinds.persist.MacroStorage; @@ -32,8 +33,10 @@ public class KeybindsClient implements ClientModInitializer { private final ExecutionEngine engine = new ExecutionEngine(); private final TriggerContext triggers = new TriggerContext(); private final EdgeDetector holdEdges = new EdgeDetector(); + private final TriggerIndex triggerIndex = new TriggerIndex(); private int activeHoldIndex = NONE; + private int indexedRevision = -1; private List configMacros; @Override @@ -57,8 +60,15 @@ public void onInitializeClient() { } KeybindsConfig config = KeybindsConfig.get(); + boolean rebuilt = false; if (configMacros == null) { configMacros = ConfigMacros.build(config); + rebuilt = true; + } + int revision = MacroRegistry.get().revision(); + if (rebuilt || revision != indexedRevision) { + triggerIndex.rebuild(configMacros, MacroRegistry.get().all()); + indexedRevision = revision; } if (holdEdges.justPressed(GLFW.GLFW_KEY_RIGHT_SHIFT, @@ -72,8 +82,7 @@ public void onInitializeClient() { triggers.begin(client); pollHolds(client, config); - fireMacros(client, configMacros); - fireMacros(client, MacroRegistry.get().all()); + fireMacros(client, triggerIndex.candidates(triggers)); triggers.commit(); holdEdges.commit(); }); diff --git a/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java index 41cc94f..d2fe8e6 100644 --- a/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java +++ b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java @@ -10,6 +10,7 @@ public final class MacroRegistry { private static final MacroRegistry INSTANCE = new MacroRegistry(); private final List macros = new ArrayList<>(); + private int revision; public static MacroRegistry get() { return INSTANCE; @@ -30,14 +31,21 @@ public Macro byId(UUID id) { public void add(Macro macro) { macros.add(macro); + revision++; } public void remove(Macro macro) { macros.remove(macro); + revision++; } public void replaceAll(List replacement) { macros.clear(); macros.addAll(replacement); + revision++; + } + + public int revision() { + return revision; } } diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java new file mode 100644 index 0000000..483f44a --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java @@ -0,0 +1,22 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record HeldDurabilityTrigger(int durability) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.HELD_DURABILITY; + } + + @Override + public void write(JsonObject out) { + out.addProperty("durability", durability); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = ctx.heldDurabilityAtMost(durability); + return new TriggerResult(active, ctx.edge(this, active)); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java new file mode 100644 index 0000000..c992b45 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java @@ -0,0 +1,25 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record KeyReleaseTrigger(int key) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.KEY_RELEASE; + } + + @Override + public void write(JsonObject out) { + out.addProperty("key", key); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + if (key < 0) { + return new TriggerResult(false, false); + } + boolean released = ctx.edge(this, !ctx.keyDown(key)); + return new TriggerResult(released, released); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java new file mode 100644 index 0000000..d38c006 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java @@ -0,0 +1,25 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record MouseReleaseTrigger(int button) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.MOUSE_RELEASE; + } + + @Override + public void write(JsonObject out) { + out.addProperty("button", button); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + if (button < 0) { + return new TriggerResult(false, false); + } + boolean released = ctx.edge(this, !ctx.mouseDown(button)); + return new TriggerResult(released, released); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java new file mode 100644 index 0000000..35c7f8c --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java @@ -0,0 +1,21 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record RidingTrigger() implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.RIDING; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = ctx.riding(); + return new TriggerResult(active, ctx.edge(this, active)); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java new file mode 100644 index 0000000..1977e6c --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java @@ -0,0 +1,22 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonObject; + +public record SlotSelectedTrigger(int slot) implements Trigger { + + @Override + public TriggerType type() { + return TriggerTypes.SLOT_SELECTED; + } + + @Override + public void write(JsonObject out) { + out.addProperty("slot", slot); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = slot >= 0 && ctx.selectedSlot() == slot; + return new TriggerResult(active, ctx.edge(this, active)); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java index 5182b29..cf2e204 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java @@ -4,6 +4,7 @@ import com.kingjayan.keybinds.game.ItemLookup; import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.Minecraft; +import net.minecraft.world.item.ItemStack; import org.lwjgl.glfw.GLFW; import java.util.IdentityHashMap; @@ -34,6 +35,23 @@ public boolean conditionActive(String id) { return client != null && PlayerConditions.active(id, client.player); } + public int selectedSlot() { + return client != null && client.player != null + ? client.player.getInventory().getSelectedSlot() : -1; + } + + public boolean riding() { + return client != null && client.player != null && client.player.isPassenger(); + } + + public boolean heldDurabilityAtMost(int durability) { + if (client == null || client.player == null) { + return false; + } + ItemStack held = client.player.getMainHandItem(); + return held.isDamageableItem() && held.getMaxDamage() - held.getDamageValue() <= durability; + } + public boolean holdingAny(List items) { if (items == null || items.isEmpty()) { return true; diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java new file mode 100644 index 0000000..722376f --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java @@ -0,0 +1,112 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.kingjayan.keybinds.core.macro.Macro; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class TriggerIndex { + + private final Map> byKey = new HashMap<>(); + private final Map> byMouseButton = new HashMap<>(); + private final List polled = new ArrayList<>(); + private final Map order = new IdentityHashMap<>(); + + public void rebuild(List first, List second) { + byKey.clear(); + byMouseButton.clear(); + polled.clear(); + order.clear(); + index(first); + index(second); + } + + public List candidates(TriggerContext ctx) { + List result = new ArrayList<>(polled); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + seen.addAll(polled); + for (Map.Entry> entry : byKey.entrySet()) { + if (ctx.keyDown(entry.getKey())) { + addUnique(result, seen, entry.getValue()); + } + } + for (Map.Entry> entry : byMouseButton.entrySet()) { + if (ctx.mouseDown(entry.getKey())) { + addUnique(result, seen, entry.getValue()); + } + } + result.sort(Comparator.comparingInt(order::get)); + return result; + } + + private void addUnique(List result, Set seen, List macros) { + for (Macro macro : macros) { + if (seen.add(macro)) { + result.add(macro); + } + } + } + + private void index(List macros) { + for (Macro macro : macros) { + if (macro.trigger() == null) { + continue; + } + Classification classification = new Classification(); + classify(macro.trigger(), classification); + if (classification.polled) { + order.put(macro, order.size()); + polled.add(macro); + } else if (!classification.keys.isEmpty() || !classification.buttons.isEmpty()) { + order.put(macro, order.size()); + for (int key : classification.keys) { + byKey.computeIfAbsent(key, unused -> new ArrayList<>()).add(macro); + } + for (int button : classification.buttons) { + byMouseButton.computeIfAbsent(button, unused -> new ArrayList<>()).add(macro); + } + } + } + } + + private void classify(Trigger trigger, Classification out) { + switch (trigger) { + case KeyTrigger key -> { + if (key.key() >= 0) { + out.keys.add(key.key()); + } + } + case MouseButtonTrigger mouse -> { + if (mouse.button() >= 0) { + out.buttons.add(mouse.button()); + } + } + case AllOfTrigger all -> { + for (Trigger child : all.children()) { + classify(child, out); + } + } + case AnyOfTrigger any -> { + for (Trigger child : any.children()) { + classify(child, out); + } + } + case NotTrigger _, HoldingTrigger _ -> { + } + default -> out.polled = true; + } + } + + private static final class Classification { + final Set keys = new LinkedHashSet<>(); + final Set buttons = new LinkedHashSet<>(); + boolean polled; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java index 38da5ec..f966489 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java @@ -36,6 +36,21 @@ public final class TriggerTypes { public static final TriggerType HOLDING = TriggerRegistry.register(new TriggerType("holding", json -> new HoldingTrigger(readStrings(json)))); + public static final TriggerType KEY_RELEASE = TriggerRegistry.register(new TriggerType("key_release", + json -> new KeyReleaseTrigger(GsonHelper.getAsInt(json, "key", GLFW.GLFW_KEY_UNKNOWN)))); + + public static final TriggerType MOUSE_RELEASE = TriggerRegistry.register(new TriggerType("mouse_release", + json -> new MouseReleaseTrigger(GsonHelper.getAsInt(json, "button", -1)))); + + public static final TriggerType SLOT_SELECTED = TriggerRegistry.register(new TriggerType("slot_selected", + json -> new SlotSelectedTrigger(GsonHelper.getAsInt(json, "slot", -1)))); + + public static final TriggerType RIDING = TriggerRegistry.register(new TriggerType("riding", + json -> new RidingTrigger())); + + public static final TriggerType HELD_DURABILITY = TriggerRegistry.register(new TriggerType("held_durability", + json -> new HeldDurabilityTrigger(GsonHelper.getAsInt(json, "durability", 0)))); + private TriggerTypes() { } From ee6fd742c2959b9b9f19ed52fa0b9ce85798a0f0 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 17:11:37 -0500 Subject: [PATCH 08/19] add retained-mode ui framework foundations: layouts, scroll panel, modal overlay, context menu, animation --- .../keybinds/ui/framework/Animator.java | 42 +++++++ .../keybinds/ui/framework/ContextMenu.java | 74 +++++++++++ .../keybinds/ui/framework/Easing.java | 30 +++++ .../ui/framework/FrameworkScreen.java | 118 ++++++++++++++++++ .../keybinds/ui/framework/Layouts.java | 37 ++++++ .../keybinds/ui/framework/ModalOverlay.java | 90 +++++++++++++ .../keybinds/ui/framework/ScrollPanel.java | 115 +++++++++++++++++ 7 files changed, 506 insertions(+) create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java new file mode 100644 index 0000000..6de3947 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java @@ -0,0 +1,42 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.util.Mth; + +public final class Animator { + + private final Easing easing; + private float from; + private float to; + private int durationTicks; + private int elapsed; + + public Animator(Easing easing) { + this.easing = easing; + } + + public void start(float from, float to, int durationTicks) { + this.from = from; + this.to = to; + this.durationTicks = Math.max(1, durationTicks); + this.elapsed = 0; + } + + public void retarget(float to, int durationTicks) { + start(value(0.0F), to, durationTicks); + } + + public void tick() { + if (elapsed < durationTicks) { + elapsed++; + } + } + + public float value(float partialTick) { + float progress = Mth.clamp((elapsed + partialTick) / durationTicks, 0.0F, 1.0F); + return Mth.lerp(easing.apply(progress), from, to); + } + + public boolean finished() { + return elapsed >= durationTicks; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java new file mode 100644 index 0000000..d189d7d --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java @@ -0,0 +1,74 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +import java.util.List; + +public class ContextMenu extends AbstractWidget { + + private static final int PADDING = 3; + private static final int ROW_HEIGHT = 12; + private static final int BACKGROUND = 0xF0100010; + private static final int BORDER = 0xFF5000A0; + private static final int HOVER = 0x40FFFFFF; + + private final Font font; + private final List entries; + + public ContextMenu(Font font, int x, int y, List entries) { + super(x, y, menuWidth(font, entries), entries.size() * ROW_HEIGHT + PADDING * 2, Component.empty()); + this.font = font; + this.entries = entries; + } + + private static int menuWidth(Font font, List entries) { + int widest = 0; + for (Entry entry : entries) { + widest = Math.max(widest, font.width(entry.label)); + } + return widest + PADDING * 2 + 8; + } + + @Override + public void onClick(MouseButtonEvent event, boolean doubleClick) { + int index = hoveredIndex(event.y()); + if (index >= 0) { + entries.get(index).action.run(); + } + } + + private int hoveredIndex(double mouseY) { + int index = ((int) mouseY - getY() - PADDING) / ROW_HEIGHT; + return index >= 0 && index < entries.size() ? index : -1; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + int left = getX(); + int top = getY(); + int right = left + width; + int bottom = top + height; + graphics.fill(left - 1, top - 1, right + 1, bottom + 1, BORDER); + graphics.fill(left, top, right, bottom, BACKGROUND); + int hovered = isMouseOver(mouseX, mouseY) ? hoveredIndex(mouseY) : -1; + for (int i = 0; i < entries.size(); i++) { + int rowTop = top + PADDING + i * ROW_HEIGHT; + if (i == hovered) { + graphics.fill(left + 1, rowTop - 1, right - 1, rowTop + ROW_HEIGHT - 1, HOVER); + } + graphics.text(font, entries.get(i).label, left + PADDING + 2, rowTop + 1, 0xFFFFFFFF); + } + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + } + + public record Entry(Component label, Runnable action) { + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java new file mode 100644 index 0000000..7a1993d --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java @@ -0,0 +1,30 @@ +package com.kingjayan.keybinds.ui.framework; + +public enum Easing { + LINEAR { + @Override + public float apply(float t) { + return t; + } + }, + EASE_IN { + @Override + public float apply(float t) { + return t * t; + } + }, + EASE_OUT { + @Override + public float apply(float t) { + return 1.0F - (1.0F - t) * (1.0F - t); + } + }, + EASE_IN_OUT { + @Override + public float apply(float t) { + return t < 0.5F ? 2.0F * t * t : 1.0F - 2.0F * (1.0F - t) * (1.0F - t); + } + }; + + public abstract float apply(float t); +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java b/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java new file mode 100644 index 0000000..dc1eae6 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java @@ -0,0 +1,118 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +public abstract class FrameworkScreen extends Screen { + + private AbstractWidget overlay; + + protected FrameworkScreen(Component title) { + super(title); + } + + public void pushModal(ModalOverlay modal) { + overlay = modal; + } + + public void openContextMenu(ContextMenu menu) { + menu.setX(Math.max(0, Math.min(menu.getX(), width - menu.getWidth()))); + menu.setY(Math.max(0, Math.min(menu.getY(), height - menu.getHeight()))); + overlay = menu; + } + + public void closeOverlay() { + overlay = null; + } + + public boolean hasOverlay() { + return overlay != null; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + if (overlay != null) { + graphics.nextStratum(); + overlay.extractRenderState(graphics, mouseX, mouseY, a); + } + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (overlay == null) { + return super.mouseClicked(event, doubleClick); + } + if (overlay instanceof ContextMenu menu) { + boolean over = menu.isMouseOver(event.x(), event.y()); + closeOverlay(); + if (over) { + menu.mouseClicked(event, doubleClick); + } + return true; + } + overlay.mouseClicked(event, doubleClick); + return true; + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + if (overlay == null) { + return super.mouseReleased(event); + } + overlay.mouseReleased(event); + return true; + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) { + if (overlay == null) { + return super.mouseDragged(event, dx, dy); + } + overlay.mouseDragged(event, dx, dy); + return true; + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + if (overlay == null) { + return super.mouseScrolled(x, y, scrollX, scrollY); + } + overlay.mouseScrolled(x, y, scrollX, scrollY); + return true; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (overlay == null) { + return super.keyPressed(event); + } + if (overlay.keyPressed(event)) { + return true; + } + if (event.isEscape()) { + closeOverlay(); + } + return true; + } + + @Override + public boolean charTyped(CharacterEvent event) { + if (overlay == null) { + return super.charTyped(event); + } + overlay.charTyped(event); + return true; + } + + @Override + protected void rebuildWidgets() { + overlay = null; + super.rebuildWidgets(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java new file mode 100644 index 0000000..17a1525 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java @@ -0,0 +1,37 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.Layout; +import net.minecraft.client.gui.layouts.LinearLayout; + +import java.util.function.Consumer; + +public final class Layouts { + + private Layouts() { + } + + public static LinearLayout vertical(int spacing) { + return LinearLayout.vertical().spacing(spacing); + } + + public static LinearLayout horizontal(int spacing) { + return LinearLayout.horizontal().spacing(spacing); + } + + public static void arrange(Layout layout, int x, int y) { + layout.arrangeElements(); + layout.setX(x); + layout.setY(y); + } + + public static void center(Layout layout, int areaWidth, int areaHeight) { + layout.arrangeElements(); + FrameLayout.centerInRectangle(layout, 0, 0, areaWidth, areaHeight); + } + + public static void collect(Layout layout, Consumer sink) { + layout.visitWidgets(sink); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java new file mode 100644 index 0000000..0e9d95a --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java @@ -0,0 +1,90 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractContainerWidget; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +import java.util.ArrayList; +import java.util.List; + +public class ModalOverlay extends AbstractContainerWidget { + + private static final int DIM = 0x90000000; + + private final List content = new ArrayList<>(); + private final FrameworkScreen host; + private final Runnable onDismiss; + private boolean dismissOnOutsideClick = true; + + public ModalOverlay(FrameworkScreen host, Runnable onDismiss) { + super(0, 0, host.width, host.height, Component.empty()); + this.host = host; + this.onDismiss = onDismiss; + } + + public ModalOverlay dismissOnOutsideClick(boolean value) { + this.dismissOnOutsideClick = value; + return this; + } + + public T add(T widget) { + content.add(widget); + return widget; + } + + public void dismiss() { + host.closeOverlay(); + if (onDismiss != null) { + onDismiss.run(); + } + } + + @Override + public List children() { + return content; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + for (AbstractWidget widget : content) { + if (widget.isMouseOver(event.x(), event.y())) { + return super.mouseClicked(event, doubleClick) || true; + } + } + if (dismissOnOutsideClick) { + dismiss(); + } + return true; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (event.isEscape()) { + dismiss(); + return true; + } + return super.keyPressed(event); + } + + @Override + protected int contentHeight() { + return height; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.fill(0, 0, width, height, DIM); + for (AbstractWidget widget : content) { + widget.extractRenderState(graphics, mouseX, mouseY, a); + } + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java new file mode 100644 index 0000000..b7a9ccb --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java @@ -0,0 +1,115 @@ +package com.kingjayan.keybinds.ui.framework; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractContainerWidget; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +import java.util.ArrayList; +import java.util.List; + +public class ScrollPanel extends AbstractContainerWidget { + + private final List entries = new ArrayList<>(); + private final List widgets = new ArrayList<>(); + private int contentHeight; + + public ScrollPanel(int x, int y, int width, int height) { + super(x, y, width, height, Component.empty()); + } + + public T add(T widget, int relX, int relY) { + entries.add(new Entry(widget, relX, relY)); + widgets.add(widget); + contentHeight = Math.max(contentHeight, relY + widget.getHeight()); + applyPositions(); + return widget; + } + + public void clearEntries() { + entries.clear(); + widgets.clear(); + contentHeight = 0; + setScrollAmount(0.0); + } + + @Override + public List children() { + return widgets; + } + + @Override + public void setScrollAmount(double amount) { + super.setScrollAmount(amount); + applyPositions(); + } + + @Override + public void setX(int x) { + super.setX(x); + applyPositions(); + } + + @Override + public void setY(int y) { + super.setY(y); + applyPositions(); + } + + private void applyPositions() { + int offset = (int) scrollAmount(); + for (Entry entry : entries) { + entry.widget.setX(getX() + entry.relX); + entry.widget.setY(getY() + entry.relY - offset); + } + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (!visible || !isMouseOver(event.x(), event.y())) { + return false; + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + if (!visible || !isMouseOver(x, y)) { + return false; + } + return super.mouseScrolled(x, y, scrollX, scrollY); + } + + @Override + protected int contentHeight() { + return contentHeight; + } + + @Override + protected double scrollRate() { + return 12.0; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.enableScissor(getX(), getY(), getX() + width, getY() + height); + for (Entry entry : entries) { + AbstractWidget widget = entry.widget; + if (widget.getY() + widget.getHeight() >= getY() && widget.getY() <= getY() + height) { + widget.extractRenderState(graphics, mouseX, mouseY, a); + } + } + graphics.disableScissor(); + extractScrollbar(graphics, mouseX, mouseY); + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + } + + private record Entry(AbstractWidget widget, int relX, int relY) { + } +} From 97ee17d2045c95e407462920bd794db41754a6e5 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 20:34:56 -0500 Subject: [PATCH 09/19] replace legacy config screens with full macro editor and retire keybinds.json runtime path --- .../kingjayan/keybinds/BindEditScreen.java | 331 ----------------- .../java/com/kingjayan/keybinds/Keybinds.java | 9 +- .../kingjayan/keybinds/KeybindsClient.java | 99 +----- .../kingjayan/keybinds/KeybindsConfig.java | 290 --------------- .../keybinds/KeybindsConfigScreen.java | 336 ------------------ .../kingjayan/keybinds/LungeSwapScreen.java | 125 ------- .../keybinds/ModMenuIntegration.java | 6 +- .../keybinds/compat/ConfigMacros.java | 179 ---------- .../keybinds/core/macro/MacroRegistry.java | 4 + .../keybinds/core/trigger/TriggerIndex.java | 5 +- .../keybinds/serialize/MacroCodec.java | 4 +- .../keybinds/ui/MacroManagerScreen.java | 128 ------- .../keybinds/ui/editor/EditorSpecs.java | 223 ++++++++++++ .../keybinds/ui/editor/MacroEditScreen.java | 172 +++++++++ .../keybinds/ui/editor/MacroListScreen.java | 138 +++++++ .../keybinds/ui/editor/PaletteModal.java | 78 ++++ .../keybinds/ui/editor/ParamModal.java | 203 +++++++++++ .../keybinds/ui/editor/TriggerEditScreen.java | 145 ++++++++ .../keybinds/ui/framework/ModalOverlay.java | 12 +- .../keybinds/ui/framework/ScrollPanel.java | 4 + .../resources/assets/keybinds/lang/en_us.json | 88 +---- 21 files changed, 1015 insertions(+), 1564 deletions(-) delete mode 100644 src/main/java/com/kingjayan/keybinds/BindEditScreen.java delete mode 100644 src/main/java/com/kingjayan/keybinds/KeybindsConfig.java delete mode 100644 src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java delete mode 100644 src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java delete mode 100644 src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java delete mode 100644 src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java diff --git a/src/main/java/com/kingjayan/keybinds/BindEditScreen.java b/src/main/java/com/kingjayan/keybinds/BindEditScreen.java deleted file mode 100644 index 5e0bc11..0000000 --- a/src/main/java/com/kingjayan/keybinds/BindEditScreen.java +++ /dev/null @@ -1,331 +0,0 @@ -package com.kingjayan.keybinds; - -import com.mojang.blaze3d.platform.InputConstants; -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.components.StringWidget; -import net.minecraft.client.gui.components.Tooltip; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; -import org.lwjgl.glfw.GLFW; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.IntConsumer; - -/** - * Per-bind advanced editor, opened from the ⚙ button on a row of {@link KeybindsConfigScreen}. Every - * option beyond "which item" lives here so the main list stays compact: the primary trigger — a key - * or a left/right-click with a held-item filter (never both) — the additive environment - * conditions, the hotbar gate, and the visible "vanilla swap" with its per-step tick delays. Each - * control carries a small {@code ?} help button with a tooltip instead of an inline paragraph. - * - *

There are more rows than fit a short screen, and Mod Menu does not scroll the sub-screen for us, - * so the body between the fixed title and the fixed {@code Done} button scrolls on its own: content is - * laid out in a virtual coordinate space, then each widget is offset by {@link #scroll} and hidden when - * it would fall outside the viewport (so it neither draws nor takes clicks there). 26.2 renders retained - * (no immediate {@code GuiGraphics}), so scrolling is done purely by moving/hiding widgets — no custom - * drawing. Changes write straight through to the shared config object; {@code Done} persists and returns. - */ -public class BindEditScreen extends Screen { - - private static final int COLUMN_WIDTH = 320; - private static final int ROW = 22; - private static final int GAP = 4; - private static final int HELP = 20; - - private final Screen parent; - private final KeybindsConfig config; - private final KeybindsConfig.ItemUse use; - - /** Scrollable body widgets and their virtual (pre-scroll) top-Y, kept parallel. */ - private final List body = new ArrayList<>(); - private final List bodyY = new ArrayList<>(); - - /** When non-null, the next key press is captured for this setter (the key-mode bind button). */ - private IntConsumer keyCapture; - - private int left; - private int viewTop; - private int viewBottom; - private int maxScroll; - private int scroll; - - public BindEditScreen(Screen parent, KeybindsConfig config, int index) { - super(Component.translatable("keybinds.edit.title", index + 1)); - this.parent = parent; - this.config = config; - this.use = config.itemUses.get(index); - } - - @Override - protected void init() { - body.clear(); - bodyY.clear(); - - left = this.width / 2 - COLUMN_WIDTH / 2; - viewTop = 24; - viewBottom = this.height - 32; - - int contentWidth = COLUMN_WIDTH - HELP - GAP; - int y = 0; - - // Fixed header and footer (not scrolled). - addRenderableWidget(new StringWidget(0, 6, this.width, 16, this.title, this.font)); - addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(this.width / 2 - 100, this.height - 26, 200, 20) - .build()); - - // --- Item ----------------------------------------------------------------------------- - EditBox item = new EditBox(this.font, left, 0, COLUMN_WIDTH, 20, - Component.translatable("keybinds.config.item_id")); - item.setMaxLength(128); - item.setValue(use.item == null ? "" : use.item); - item.setHint(Component.translatable("keybinds.config.item_id")); - item.setResponder(t -> use.item = t); - track(item, y); - y += ROW; - - // --- Use style ------------------------------------------------------------------------ - row(y, CycleButton.onOffBuilder(use.hold) - .create(left, 0, contentWidth, 20, Component.translatable("keybinds.config.hold"), - (b, v) -> use.hold = v), "keybinds.edit.hold_help"); - y += ROW; - - // --- Primary trigger: key OR click ---------------------------------------------------- - track(new StringWidget(left, 0, COLUMN_WIDTH, 12, - Component.translatable("keybinds.edit.triggers"), this.font), y); - y += 14; - - row(y, CycleButton.builder(this::modeLabel, normalizedMode()) - .withValues(KeybindsConfig.TRIGGER_KEY, KeybindsConfig.TRIGGER_LEFT_CLICK, KeybindsConfig.TRIGGER_RIGHT_CLICK) - .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.trigger_mode"), - (b, v) -> { - use.triggerMode = v; - rebuildWidgets(); - }), "keybinds.edit.trigger_mode_help"); - y += ROW; - - // Only the relevant follow-up field for the chosen mode: the key, or the held-item filter. - if (normalizedMode().equals(KeybindsConfig.TRIGGER_KEY)) { - row(y, Button.builder(keyButtonLabel(), b -> beginCapture(code -> use.key = code)) - .bounds(left, 0, contentWidth, 20) - .build(), "keybinds.edit.key_help"); - y += ROW; - } else { - EditBox clickItems = new EditBox(this.font, left, 0, contentWidth, 20, - Component.translatable("keybinds.edit.click_items")); - clickItems.setMaxLength(512); - clickItems.setValue(String.join(", ", use.clickItems)); - clickItems.setHint(Component.translatable("keybinds.edit.click_items_hint")); - clickItems.setResponder(t -> { - List ids = new ArrayList<>(); - for (String part : t.split(",")) { - String id = part.trim(); - if (!id.isBlank()) { - ids.add(id); - } - } - use.clickItems = ids; - }); - row(y, clickItems, "keybinds.edit.click_items_help"); - y += ROW; - } - - // --- Conditions (additive) ------------------------------------------------------------ - track(new StringWidget(left, 0, COLUMN_WIDTH - HELP, 12, - Component.translatable("keybinds.edit.conditions"), this.font), y); - help(left + COLUMN_WIDTH - HELP, y - 4, "keybinds.edit.conditions_help"); - y += 14; - - int cols = 3; - int cellWidth = (COLUMN_WIDTH - (cols - 1) * GAP) / cols; - for (int i = 0; i < KeybindsConfig.CONDITIONS.size(); i++) { - String id = KeybindsConfig.CONDITIONS.get(i); - int cx = left + (i % cols) * (cellWidth + GAP); - int cy = y + (i / cols) * ROW; - track(conditionButton(cx, cellWidth, id), cy); - } - y += ROW * ((KeybindsConfig.CONDITIONS.size() + cols - 1) / cols); - - // --- Gating & vanilla swap ------------------------------------------------------------ - row(y, CycleButton.builder(this::gateLabel, normalizedGate()) - .withValues(KeybindsConfig.GATE_ANY, KeybindsConfig.GATE_IN_HOTBAR, KeybindsConfig.GATE_NOT_IN_HOTBAR) - .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.gate"), - (b, v) -> use.hotbarGate = v), "keybinds.edit.gate_help"); - y += ROW; - - row(y, CycleButton.onOffBuilder(use.vanillaSwap) - .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.vanilla_swap"), - (b, v) -> use.vanillaSwap = v), "keybinds.edit.vanilla_swap_help"); - y += ROW; - - // --- Swap tick delays ----------------------------------------------------------------- - int labelWidth = 92; - track(new StringWidget(left, 0, labelWidth, 20, - Component.translatable("keybinds.edit.delays"), this.font), y); - int dx = left + labelWidth + GAP; - int delayWidth = 40; - track(delayBox(dx, use.swapToDelay, v -> use.swapToDelay = v), y); - dx += delayWidth + GAP; - track(delayBox(dx, use.useDelay, v -> use.useDelay = v), y); - dx += delayWidth + GAP; - track(delayBox(dx, use.swapBackDelay, v -> use.swapBackDelay = v), y); - help(left + COLUMN_WIDTH - HELP, y, "keybinds.edit.delays_help"); - y += ROW; - - int contentBottom = y; - maxScroll = Math.max(0, contentBottom - (viewBottom - viewTop)); - relayout(); - } - - // --- Scrolling ----------------------------------------------------------------------------- - - /** Register a body widget at virtual top-Y {@code y}, so {@link #relayout()} can scroll and clip it. */ - private T track(T widget, int y) { - body.add(widget); - bodyY.add(y); - addRenderableWidget(widget); - return widget; - } - - /** Offset every body widget by the current scroll, hiding any that fall outside the viewport. */ - private void relayout() { - scroll = Math.max(0, Math.min(scroll, maxScroll)); - for (int i = 0; i < body.size(); i++) { - AbstractWidget widget = body.get(i); - int wy = viewTop + bodyY.get(i) - scroll; - widget.setY(wy); - // Only show rows fully inside the viewport, so nothing draws over the title/footer and no - // off-screen control can be clicked. - widget.visible = wy >= viewTop && wy + widget.getHeight() <= viewBottom; - } - } - - @Override - public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { - if (maxScroll > 0 && scrollY != 0) { - scroll = Math.max(0, Math.min(scroll - (int) Math.signum(scrollY) * ROW, maxScroll)); - this.setFocused(null); - relayout(); - return true; - } - return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); - } - - // --- Widget builders ----------------------------------------------------------------------- - - /** Add a control plus a trailing {@code ?} help button sharing its virtual row. */ - private void row(int y, AbstractWidget control, String helpKey) { - track(control, y); - help(left + COLUMN_WIDTH - HELP, y, helpKey); - } - - /** A small {@code ?} button whose only purpose is to show {@code helpKey}'s tooltip on hover. */ - private void help(int x, int y, String helpKey) { - Button helpButton = Button.builder(Component.literal("?"), b -> {}) - .bounds(x, 0, HELP, 20) - .build(); - helpButton.setTooltip(Tooltip.create(Component.translatable(helpKey))); - track(helpButton, y); - } - - /** A toggle button for one condition, checkmarked when the bind lists it. */ - private Button conditionButton(int x, int w, String id) { - Button button = Button.builder(conditionLabel(id), b -> { - if (use.conditions.contains(id)) { - use.conditions.remove(id); - } else { - use.conditions.add(id); - } - rebuildWidgets(); - }) - .bounds(x, 0, w, 20) - .build(); - button.setTooltip(Tooltip.create(Component.translatable("keybinds.cond." + id))); - return button; - } - - private Component conditionLabel(String id) { - Component name = Component.translatable("keybinds.cond." + id); - return use.conditions.contains(id) ? Component.literal("✔ ").append(name) : name; - } - - /** Numeric tick-delay box: keeps only digits and clamps to 0..40 as it is typed. */ - private EditBox delayBox(int x, int value, IntConsumer setter) { - EditBox box = new EditBox(this.font, x, 0, 40, 20, Component.translatable("keybinds.edit.delays")); - box.setMaxLength(2); - box.setValue(Integer.toString(value)); - // 26.2 dropped EditBox.setFilter, so keep only digits and clamp straight from the responder. - box.setResponder(t -> { - String digits = t.replaceAll("\\D", ""); - int parsed = digits.isBlank() ? 0 : Integer.parseInt(digits); - setter.accept(Math.max(0, Math.min(parsed, 40))); - }); - return box; - } - - // --- Key capture --------------------------------------------------------------------------- - - private Component keyButtonLabel() { - Component prefix = Component.translatable("keybinds.edit.key").copy().append(Component.literal(": ")); - Component name = KeybindsConfig.isBound(use.key) - ? InputConstants.Type.KEYSYM.getOrCreate(use.key).getDisplayName() - : Component.translatable("keybinds.config.unbound"); - return prefix.copy().append(name); - } - - private void beginCapture(IntConsumer setter) { - this.setFocused(null); - this.keyCapture = setter; - } - - @Override - public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { - if (keyCapture != null) { - // Escape clears the bind; any other key becomes the new binding. - int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? KeybindsConfig.UNBOUND : event.key(); - keyCapture.accept(assigned); - keyCapture = null; - rebuildWidgets(); - return true; - } - return super.keyPressed(event); - } - - // --- Label / value helpers ----------------------------------------------------------------- - - private String normalizedMode() { - String m = use.triggerMode; - if (KeybindsConfig.TRIGGER_LEFT_CLICK.equals(m) || KeybindsConfig.TRIGGER_RIGHT_CLICK.equals(m)) { - return m; - } - return KeybindsConfig.TRIGGER_KEY; - } - - private Component modeLabel(String mode) { - return Component.translatable("keybinds.mode." + mode); - } - - private String normalizedGate() { - String g = use.hotbarGate; - if (KeybindsConfig.GATE_IN_HOTBAR.equals(g) || KeybindsConfig.GATE_NOT_IN_HOTBAR.equals(g)) { - return g; - } - return KeybindsConfig.GATE_ANY; - } - - private Component gateLabel(String gate) { - return Component.translatable("keybinds.gate." + gate); - } - - @Override - public void onClose() { - config.save(); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(parent); - } - } -} diff --git a/src/main/java/com/kingjayan/keybinds/Keybinds.java b/src/main/java/com/kingjayan/keybinds/Keybinds.java index 2fdbb9c..1d019d8 100644 --- a/src/main/java/com/kingjayan/keybinds/Keybinds.java +++ b/src/main/java/com/kingjayan/keybinds/Keybinds.java @@ -1,12 +1,11 @@ package com.kingjayan.keybinds; -import net.fabricmc.api.ModInitializer; - -import net.minecraft.resources.Identifier; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import net.fabricmc.api.ModInitializer; +import net.minecraft.resources.Identifier; + public class Keybinds implements ModInitializer { public static final String MOD_ID = "keybinds"; @@ -14,7 +13,7 @@ public class Keybinds implements ModInitializer { @Override public void onInitialize() { - LOGGER.info("keybinds initialized (client-side macro framework)"); + LOGGER.info("keybinds initialized"); } public static Identifier id(String path) { diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java index e5209f1..dfef8d3 100644 --- a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java @@ -1,6 +1,5 @@ package com.kingjayan.keybinds; -import com.kingjayan.keybinds.compat.ConfigMacros; import com.kingjayan.keybinds.core.action.ActionTypes; import com.kingjayan.keybinds.core.exec.ExecutionEngine; import com.kingjayan.keybinds.core.input.EdgeDetector; @@ -10,34 +9,25 @@ import com.kingjayan.keybinds.core.trigger.TriggerContext; import com.kingjayan.keybinds.core.trigger.TriggerIndex; import com.kingjayan.keybinds.core.trigger.TriggerTypes; -import com.kingjayan.keybinds.game.PlayerActions; import com.kingjayan.keybinds.persist.MacroStorage; -import com.kingjayan.keybinds.ui.MacroManagerScreen; +import com.kingjayan.keybinds.ui.editor.MacroListScreen; import com.mojang.blaze3d.platform.InputConstants; -import com.mojang.blaze3d.platform.Window; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.client.Minecraft; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.world.entity.player.Inventory; -import net.minecraft.world.item.Item; import org.lwjgl.glfw.GLFW; import java.util.List; public class KeybindsClient implements ClientModInitializer { - private static final int NONE = -1; - private final TickScheduler scheduler = new TickScheduler(); private final ExecutionEngine engine = new ExecutionEngine(); private final TriggerContext triggers = new TriggerContext(); - private final EdgeDetector holdEdges = new EdgeDetector(); + private final EdgeDetector screenKeyEdges = new EdgeDetector(); private final TriggerIndex triggerIndex = new TriggerIndex(); - private int activeHoldIndex = NONE; private int indexedRevision = -1; - private List configMacros; @Override public void onInitializeClient() { @@ -50,41 +40,30 @@ public void onInitializeClient() { engine.tick(); if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) { - if (activeHoldIndex != NONE) { - releaseHold(client); - } triggers.reset(); - holdEdges.reset(); - configMacros = null; + screenKeyEdges.reset(); return; } - KeybindsConfig config = KeybindsConfig.get(); - boolean rebuilt = false; - if (configMacros == null) { - configMacros = ConfigMacros.build(config); - rebuilt = true; - } int revision = MacroRegistry.get().revision(); - if (rebuilt || revision != indexedRevision) { - triggerIndex.rebuild(configMacros, MacroRegistry.get().all()); + if (revision != indexedRevision) { + triggerIndex.rebuild(MacroRegistry.get().all()); indexedRevision = revision; } - if (holdEdges.justPressed(GLFW.GLFW_KEY_RIGHT_SHIFT, + if (screenKeyEdges.justPressed(GLFW.GLFW_KEY_RIGHT_SHIFT, InputConstants.isKeyDown(client.getWindow(), GLFW.GLFW_KEY_RIGHT_SHIFT)) - && !holdEdges.resyncing()) { - client.setScreenAndShow(new MacroManagerScreen(null)); + && !screenKeyEdges.resyncing()) { + client.setScreenAndShow(new MacroListScreen(null)); triggers.reset(); - holdEdges.reset(); + screenKeyEdges.reset(); return; } triggers.begin(client); - pollHolds(client, config); fireMacros(client, triggerIndex.candidates(triggers)); triggers.commit(); - holdEdges.commit(); + screenKeyEdges.commit(); }); } @@ -98,62 +77,4 @@ private void fireMacros(Minecraft client, List macros) { } } } - - private void pollHolds(Minecraft client, KeybindsConfig config) { - Window window = client.getWindow(); - List uses = config.itemUses; - int count = Math.min(uses.size(), KeybindsConfig.MAX_ITEM_USES); - - if (activeHoldIndex != NONE) { - KeybindsConfig.ItemUse held = activeHoldIndex < count ? uses.get(activeHoldIndex) : null; - boolean stillHeld = held != null && held.hold && KeybindsConfig.isBound(held.key) - && InputConstants.isKeyDown(window, held.key); - if (stillHeld) { - client.options.keyUse.setDown(true); - } else { - releaseHold(client); - } - } - - boolean resync = holdEdges.resyncing(); - for (int i = 0; i < count; i++) { - KeybindsConfig.ItemUse use = uses.get(i); - String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; - if (!use.hold || !mode.equals(KeybindsConfig.TRIGGER_KEY) || !KeybindsConfig.isBound(use.key)) { - continue; - } - boolean pressed = holdEdges.justPressed(use.key, InputConstants.isKeyDown(window, use.key)); - if (!resync && pressed && activeHoldIndex == NONE) { - Item item = KeybindsConfig.resolveItem(use.item); - if (item != null) { - startHold(client, i, item); - } - } - } - } - - private void startHold(Minecraft client, int index, Item item) { - LocalPlayer player = client.player; - if (player == null) { - return; - } - Inventory inventory = player.getInventory(); - int slot = PlayerActions.findHotbarSlot(inventory, item); - if (slot < 0) { - if (!player.getMainHandItem().is(item)) { - return; - } - slot = inventory.getSelectedSlot(); - } - PlayerActions.selectHotbarSlot(client, inventory, slot); - activeHoldIndex = index; - client.options.keyUse.setDown(true); - } - - private void releaseHold(Minecraft client) { - activeHoldIndex = NONE; - if (client.options != null) { - client.options.keyUse.setDown(false); - } - } } diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsConfig.java b/src/main/java/com/kingjayan/keybinds/KeybindsConfig.java deleted file mode 100644 index 3e6fe18..0000000 --- a/src/main/java/com/kingjayan/keybinds/KeybindsConfig.java +++ /dev/null @@ -1,290 +0,0 @@ -package com.kingjayan.keybinds; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.world.item.Item; -import org.lwjgl.glfw.GLFW; - -import java.io.IOException; -import java.io.Reader; -import java.io.Writer; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -/** - * Persistent, human-editable settings for the mod, and what the Mod Menu button opens. - * - *

The heart of it is {@link #itemUses}: a numbered, fully user-defined list of "Use Item" binds. - * Each entry names an item (any item id with a right-click action — a throwable, a block to place, a - * bow/crossbow, food, ...), whether the key should be tapped once or held, and — crucially — its own - * key. Keys are stored here, not as vanilla key mappings, so the mod polls them itself. That - * means slots are fully dynamic: adding, removing, or rebinding one takes effect immediately, with no - * game restart and no entries cluttering Options → Controls. - * - *

The two combat helpers (shield/totem swap, mace stun-slam) work the same way — each has its own - * bound key here and is active whenever that key is bound (see {@link #isBound(int)}). - * - *

The file lives at {@code config/keybinds.json}. Missing fields fall back to the defaults below, - * so older files keep loading after updates. - */ -public class KeybindsConfig { - - /** GLFW key value meaning "no key bound"; such a bind is inert. */ - public static final int UNBOUND = GLFW.GLFW_KEY_UNKNOWN; - - /** - * Sanity cap on the number of use-item slots. Purely a UI/limit guard now — because keys are - * polled rather than registered as vanilla mappings, slots are added and removed live with no - * restart, so this only keeps the paged list bounded. - */ - public static final int MAX_ITEM_USES = 32; - - /** Hotbar-gate values for {@link ItemUse#hotbarGate}: fire regardless, only-if-on-hotbar, only-if-not. */ - public static final String GATE_ANY = "any"; - public static final String GATE_IN_HOTBAR = "in_hotbar"; - public static final String GATE_NOT_IN_HOTBAR = "not_in_hotbar"; - - /** - * Primary-trigger modes for {@link ItemUse#triggerMode}: a bind fires from either its key - * or a left/right-click while holding one of its {@link ItemUse#clickItems} — never both. - * Environment {@link ItemUse#conditions} are a separate, additive axis on top of whichever is chosen. - */ - public static final String TRIGGER_KEY = "key"; - public static final String TRIGGER_LEFT_CLICK = "left_click"; - public static final String TRIGGER_RIGHT_CLICK = "right_click"; - - /** - * The fixed set of environment-condition triggers, by id. Each is a boolean the mod evaluates each - * tick; a bind with any of these listed fires when any of them flips from false to true - * (edge-triggered, so it fires once per onset, not every tick while true). Thresholds are pre-set. - */ - public static final List CONDITIONS = com.kingjayan.keybinds.core.condition.PlayerConditions.ids(); - - /** - * One user-defined bind. At its core it names an item to right-click ({@link #item}) and a tap/hold - * style ({@link #hold}). Its primary trigger is either a key or a left/right-click - * while holding a chosen item set — selected by {@link #triggerMode} (with {@link #key} used only in - * key mode and {@link #clickItems} only in the click modes). Pre-set environment {@link #conditions} - * are an additional, additive trigger on top. {@link #hotbarGate} restricts firing by where the item - * is, and {@link #vanillaSwap} plus the tick delays make an inventory-buried use a visible swap. - */ - public static class ItemUse { - /** Registry id of the item to use, e.g. {@code minecraft:wind_charge} or {@code minecraft:white_wool}. */ - public String item = "minecraft:wind_charge"; - /** - * {@code false}: a single right-click (throwables, eggs, splash potions, placing a block, - * firing an already-loaded crossbow). {@code true}: hold to use, so multi-tick actions can - * finish — drawing a bow (fires on release), loading a crossbow, eating food, spyglass, etc. - * Hold only applies to the key trigger; click/condition triggers always do a single use. - */ - public boolean hold = false; - /** GLFW key code that triggers this bind (key mode only), or {@link #UNBOUND}. */ - public int key = UNBOUND; - - // --- Primary trigger: either the key above, or a click ---------------------------------- - /** One of {@link #TRIGGER_KEY}/{@link #TRIGGER_LEFT_CLICK}/{@link #TRIGGER_RIGHT_CLICK}. */ - public String triggerMode = TRIGGER_KEY; - /** Item ids that must be held for a click-mode trigger to fire; empty means any held item qualifies. */ - public List clickItems = new ArrayList<>(); - /** Environment-condition ids (subset of {@link #CONDITIONS}); also fires on the onset of any of them. */ - public List conditions = new ArrayList<>(); - - // --- Gating & vanilla-swap behavior ----------------------------------------------------- - /** One of {@link #GATE_ANY}/{@link #GATE_IN_HOTBAR}/{@link #GATE_NOT_IN_HOTBAR}. */ - public String hotbarGate = GATE_ANY; - /** - * When the item is buried in the main inventory, do a visible legit-looking swap (bring it to - * the held slot, right-click, swap it back) instead of the instant silent container swap. - */ - public boolean vanillaSwap = false; - /** Ticks to wait before the swap-to, before the use, and before swapping back (vanilla-swap only). */ - public int swapToDelay = 2; - public int useDelay = 3; - public int swapBackDelay = 2; - - public ItemUse() { - } - - public ItemUse(String item, boolean hold, int key) { - this.item = item; - this.hold = hold; - this.key = key; - } - } - - // --- User-defined use-item binds ----------------------------------------------------------- - public List itemUses = defaultItemUses(); - - // --- Combat helpers: each active whenever its key is bound ---------------------------------- - /** Key that toggles the offhand between a shield and a totem of undying. */ - public int shieldTotemKey = GLFW.GLFW_KEY_H; - /** Key that runs the two-tick axe-then-mace stun slam. */ - public int maceStunSlamKey = GLFW.GLFW_KEY_V; - - /** After a mace stun-slam, switch back to the hotbar slot that was selected before it started. */ - public boolean returnToStartSlotAfterSlam = false; - - /** Vanilla mode: perform the swap the slower, visible (tick-spaced) way instead of instantly. */ - public boolean shieldTotemVanilla = false; - public boolean maceStunSlamVanilla = false; - - // --- Lunge swap combat helper -------------------------------------------------------------- - /** - * Swaps to a spear (picking the one with the highest {@link #lungeEnchant} level), left-clicks with - * it, then returns to the original slot. Fires from any of its three triggers — a key and/or a - * left/right-click — while the main hand passes {@link #lungeSwapHeldItems}. All default off/unbound. - */ - public int lungeSwapKey = UNBOUND; - public boolean lungeSwapLeftClick = false; - public boolean lungeSwapRightClick = false; - /** Main-hand items that allow a lunge trigger to fire; empty = any. Use {@code minecraft:air} for an empty hand. */ - public List lungeSwapHeldItems = new ArrayList<>(); - /** Spear item ids to consider; among those carried, the highest-lunge one is used. Empty = any lunge-enchanted item. */ - public List spearItems = defaultSpears(); - /** Enchantment id used to rank spears by level. */ - public String lungeEnchant = "minecraft:lunge"; - /** Vanilla mode: visibly switch to the spear over a few ticks, attack, then switch back. */ - public boolean lungeSwapVanilla = false; - - // --- Shared combat-helper swap timing (used by whichever helper has vanilla mode on) -------- - public int combatSwapToDelay = 2; - public int combatUseDelay = 3; - public int combatSwapBackDelay = 2; - - private static List defaultSpears() { - List list = new ArrayList<>(); - list.add("minecraft:wooden_spear"); - list.add("minecraft:stone_spear"); - list.add("minecraft:iron_spear"); - list.add("minecraft:golden_spear"); - list.add("minecraft:diamond_spear"); - list.add("minecraft:netherite_spear"); - return list; - } - - private static List defaultItemUses() { - // Mirrors the historical defaults: #1 throws a wind charge on R, #2 an ender pearl on G. - List list = new ArrayList<>(); - list.add(new ItemUse("minecraft:wind_charge", false, GLFW.GLFW_KEY_R)); - list.add(new ItemUse("minecraft:ender_pearl", false, GLFW.GLFW_KEY_G)); - return list; - } - - /** Whether a stored key code actually names a key (i.e. is not {@link #UNBOUND}). */ - public static boolean isBound(int key) { - return key != UNBOUND; - } - - /** Resolve an item id string to its {@link Item}, or {@code null} if it is blank or unknown. */ - public static Item resolveItem(String id) { - return com.kingjayan.keybinds.game.ItemLookup.resolveItem(id); - } - - // --- Persistence --------------------------------------------------------------------------- - - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - private static KeybindsConfig instance; - - private static Path path() { - return FabricLoader.getInstance().getConfigDir().resolve(Keybinds.MOD_ID + ".json"); - } - - /** The live, shared config instance, loaded from disk on first access. */ - public static KeybindsConfig get() { - if (instance == null) { - instance = load(); - } - return instance; - } - - private static KeybindsConfig load() { - Path file = path(); - if (Files.exists(file)) { - try (Reader reader = Files.newBufferedReader(file)) { - KeybindsConfig loaded = GSON.fromJson(reader, KeybindsConfig.class); - if (loaded != null) { - loaded.normalize(); - return loaded; - } - } catch (IOException | RuntimeException e) { - Keybinds.LOGGER.warn("Could not read {}, using defaults", file, e); - } - } - KeybindsConfig fresh = new KeybindsConfig(); - fresh.save(); - return fresh; - } - - /** Fix up anything a hand-edited or older file might be missing after deserialization. */ - private void normalize() { - if (itemUses == null) { - itemUses = defaultItemUses(); - } - itemUses.removeIf(u -> u == null); - if (itemUses.size() > MAX_ITEM_USES) { - Keybinds.LOGGER.warn("Trimming use-item binds from {} to the {}-slot cap", itemUses.size(), MAX_ITEM_USES); - itemUses.subList(MAX_ITEM_USES, itemUses.size()).clear(); - } - for (ItemUse u : itemUses) { - if (u.item == null) { - u.item = ""; - } - if (u.clickItems == null) { - u.clickItems = new ArrayList<>(); - } - u.clickItems.removeIf(s -> s == null || s.isBlank()); - if (u.conditions == null) { - u.conditions = new ArrayList<>(); - } - u.conditions.removeIf(c -> c == null || !CONDITIONS.contains(c)); - if (u.hotbarGate == null - || !(u.hotbarGate.equals(GATE_ANY) || u.hotbarGate.equals(GATE_IN_HOTBAR) || u.hotbarGate.equals(GATE_NOT_IN_HOTBAR))) { - u.hotbarGate = GATE_ANY; - } - if (u.triggerMode == null - || !(u.triggerMode.equals(TRIGGER_KEY) || u.triggerMode.equals(TRIGGER_LEFT_CLICK) || u.triggerMode.equals(TRIGGER_RIGHT_CLICK))) { - u.triggerMode = TRIGGER_KEY; - } - u.swapToDelay = clampDelay(u.swapToDelay); - u.useDelay = clampDelay(u.useDelay); - u.swapBackDelay = clampDelay(u.swapBackDelay); - } - - // Lunge swap + shared combat-helper fields. - if (lungeSwapHeldItems == null) { - lungeSwapHeldItems = new ArrayList<>(); - } - lungeSwapHeldItems.removeIf(s -> s == null || s.isBlank()); - if (spearItems == null) { - spearItems = defaultSpears(); - } - spearItems.removeIf(s -> s == null || s.isBlank()); - if (lungeEnchant == null || lungeEnchant.isBlank()) { - lungeEnchant = "minecraft:lunge"; - } - combatSwapToDelay = clampDelay(combatSwapToDelay); - combatUseDelay = clampDelay(combatUseDelay); - combatSwapBackDelay = clampDelay(combatSwapBackDelay); - } - - /** Keep tick delays within a sane, bounded range. */ - private static int clampDelay(int ticks) { - return Math.max(0, Math.min(ticks, 40)); - } - - /** Write the current values back to {@code config/keybinds.json}. */ - public void save() { - Path file = path(); - try { - Files.createDirectories(file.getParent()); - try (Writer writer = Files.newBufferedWriter(file)) { - GSON.toJson(this, writer); - } - } catch (IOException e) { - Keybinds.LOGGER.warn("Could not write {}", file, e); - } - } -} diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java b/src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java deleted file mode 100644 index 583ae20..0000000 --- a/src/main/java/com/kingjayan/keybinds/KeybindsConfigScreen.java +++ /dev/null @@ -1,336 +0,0 @@ -package com.kingjayan.keybinds; - -import com.mojang.blaze3d.platform.InputConstants; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.components.StringWidget; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.lwjgl.glfw.GLFW; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.IntConsumer; - -/** - * The mod's settings screen, opened from Mod Menu's config button. This is the single place all keys - * are assigned — the mod registers no vanilla key mappings. Each Use Item row is an item id, - * a bind-key button, a tap/hold toggle, and a remove button, with add/remove and paging. Below the - * list, the two combat helpers each get their own bind-key button plus the slam's return-slot toggle. - * - *

Clicking a bind button starts listening: the next key press is captured for that bind, and - * pressing Escape clears it (unbinds). Because keys are polled from config rather than - * registered at launch, everything here takes effect immediately — no game restart. "Done" saves to - * {@code config/keybinds.json}. Deliberately dependency-free: plain vanilla widgets only. - */ -public class KeybindsConfigScreen extends Screen { - - private static final int COLUMN_WIDTH = 320; - private static final int ROW_HEIGHT = 24; - private static final int ROWS_PER_PAGE = 5; - - private static final int EDIT_WIDTH = 160; - private static final int KEY_WIDTH = 92; - private static final int VANILLA_WIDTH = 92; - private static final int GEAR_WIDTH = 20; - private static final int REMOVE_WIDTH = 20; - private static final int GAP = 4; - - private final Screen parent; - private final KeybindsConfig config; - - private int page = 0; - - /** When non-null, the screen is listening for a key press to feed to this setter. */ - private IntConsumer keyCapture; - - /** Every item id (and, for vanilla items, its bare path) — the autocomplete source, built once. */ - private static List completions; - - public KeybindsConfigScreen(Screen parent) { - super(Component.translatable("keybinds.config.title")); - this.parent = parent; - this.config = KeybindsConfig.get(); - } - - @Override - protected void init() { - int left = this.width / 2 - COLUMN_WIDTH / 2; - int y = 24; - - addRenderableWidget(new StringWidget(0, 8, this.width, 20, this.title, this.font)); - - // --- Use Item binds ------------------------------------------------------------------- - addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, - Component.translatable("keybinds.config.uses_header"), this.font)); - y += 14; - addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, - Component.translatable("keybinds.config.uses_note"), this.font)); - y += 16; - - int totalPages = Math.max(1, (config.itemUses.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE); - page = Math.max(0, Math.min(page, totalPages - 1)); - - int first = page * ROWS_PER_PAGE; - int last = Math.min(first + ROWS_PER_PAGE, config.itemUses.size()); - for (int i = first; i < last; i++) { - addUseRow(left, y, i); - y += ROW_HEIGHT; - } - - // --- Add / paging row ----------------------------------------------------------------- - boolean canAdd = config.itemUses.size() < KeybindsConfig.MAX_ITEM_USES; - Button add = Button.builder(Component.translatable("keybinds.config.add_slot"), b -> { - config.itemUses.add(new KeybindsConfig.ItemUse("", false, KeybindsConfig.UNBOUND)); - page = (config.itemUses.size() - 1) / ROWS_PER_PAGE; - rebuildWidgets(); - }) - .bounds(left, y, EDIT_WIDTH, 20) - .build(); - add.active = canAdd; - addRenderableWidget(add); - - Button prev = Button.builder(Component.literal("◄"), b -> { - page--; - rebuildWidgets(); - }) - .bounds(left + EDIT_WIDTH + GAP, y, 32, 20) - .build(); - prev.active = page > 0; - addRenderableWidget(prev); - - Button next = Button.builder(Component.literal("►"), b -> { - page++; - rebuildWidgets(); - }) - .bounds(left + EDIT_WIDTH + GAP + 36, y, 32, 20) - .build(); - next.active = page < totalPages - 1; - addRenderableWidget(next); - - y += ROW_HEIGHT + 4; - - // --- Combat helpers ------------------------------------------------------------------- - addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, - Component.translatable("keybinds.config.helpers_header"), this.font)); - y += 16; - - // Each helper: a bind-key button plus a "Vanilla" toggle that makes its swap slow/visible. - int keyCol = COLUMN_WIDTH - VANILLA_WIDTH - GAP; - addRenderableWidget(labelledKeyButton(left, y, keyCol, "keybinds.config.shield_totem", - config.shieldTotemKey, code -> config.shieldTotemKey = code)); - addRenderableWidget(vanillaCycle(left + keyCol + GAP, y, VANILLA_WIDTH, - config.shieldTotemVanilla, v -> config.shieldTotemVanilla = v)); - y += ROW_HEIGHT; - addRenderableWidget(labelledKeyButton(left, y, keyCol, "keybinds.config.mace_slam", - config.maceStunSlamKey, code -> config.maceStunSlamKey = code)); - addRenderableWidget(vanillaCycle(left + keyCol + GAP, y, VANILLA_WIDTH, - config.maceStunSlamVanilla, v -> config.maceStunSlamVanilla = v)); - y += ROW_HEIGHT; - - addRenderableWidget(CycleButton.onOffBuilder(config.returnToStartSlotAfterSlam) - .create(left, y, COLUMN_WIDTH, 20, Component.translatable("keybinds.config.return_slot"), - (button, value) -> config.returnToStartSlotAfterSlam = value)); - y += ROW_HEIGHT; - - // Lunge swap: key button + vanilla toggle + a ⚙ opening its own editor for the extra options. - int lungeKeyCol = COLUMN_WIDTH - VANILLA_WIDTH - GEAR_WIDTH - 2 * GAP; - addRenderableWidget(labelledKeyButton(left, y, lungeKeyCol, "keybinds.config.lunge_swap", - config.lungeSwapKey, code -> config.lungeSwapKey = code)); - addRenderableWidget(vanillaCycle(left + lungeKeyCol + GAP, y, VANILLA_WIDTH, - config.lungeSwapVanilla, v -> config.lungeSwapVanilla = v)); - Button lungeGear = Button.builder(Component.literal("⚙"), b -> openLungeEditor()) - .bounds(left + lungeKeyCol + GAP + VANILLA_WIDTH + GAP, y, GEAR_WIDTH, 20) - .build(); - lungeGear.setTooltip(net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("keybinds.config.lunge_edit_tooltip"))); - addRenderableWidget(lungeGear); - - addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(this.width / 2 - 100, this.height - 28, 200, 20) - .build()); - } - - /** A "Vanilla" on/off toggle with a shared help tooltip, for a combat helper's swap style. */ - private CycleButton vanillaCycle(int x, int y, int w, boolean initial, - java.util.function.Consumer setter) { - CycleButton button = CycleButton.onOffBuilder(initial) - .create(x, y, w, 20, Component.translatable("keybinds.config.vanilla"), - (b, v) -> setter.accept(v)); - button.setTooltip(net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("keybinds.config.vanilla_help"))); - return button; - } - - /** Persist current values and open the lunge-swap advanced editor. */ - private void openLungeEditor() { - config.save(); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(new LungeSwapScreen(this, config)); - } - } - - /** One editable use-item row: item-id box, bind-key button, tap/hold toggle, remove button. */ - private void addUseRow(int left, int y, int index) { - KeybindsConfig.ItemUse use = config.itemUses.get(index); - int x = left; - - EditBox box = new EditBox(this.font, x, y, EDIT_WIDTH, 20, - Component.translatable("keybinds.config.item_id")); - box.setMaxLength(128); - box.setValue(use.item == null ? "" : use.item); - box.setHint(Component.literal("#" + (index + 1) + " item")); - box.setResponder(text -> { - use.item = text; - box.setTextColor(itemColor(text)); - box.setSuggestion(bestCompletionSuffix(text)); - }); - box.setTextColor(itemColor(use.item)); - box.setSuggestion(bestCompletionSuffix(box.getValue())); - addRenderableWidget(box); - x += EDIT_WIDTH + GAP; - - addRenderableWidget(triggerButton(x, y, KEY_WIDTH, index, use)); - x += KEY_WIDTH + GAP; - - Button gear = Button.builder(Component.literal("⚙"), b -> openEditor(index)) - .bounds(x, y, GEAR_WIDTH, 20) - .build(); - gear.setTooltip(net.minecraft.client.gui.components.Tooltip.create( - Component.translatable("keybinds.config.edit_tooltip"))); - addRenderableWidget(gear); - x += GEAR_WIDTH + GAP; - - addRenderableWidget(Button.builder(Component.literal("✕"), b -> { - config.itemUses.remove(index); - rebuildWidgets(); - }) - .bounds(x, y, REMOVE_WIDTH, 20) - .build()); - } - - /** A bare bind-key button showing just the current key's name. */ - private Button keyButton(int x, int y, int w, int currentKey, IntConsumer setter) { - return Button.builder(keyLabel(currentKey), b -> beginCapture(setter)) - .bounds(x, y, w, 20) - .build(); - } - - /** - * The row's trigger control. In key mode it captures a key straight from the list. In a click mode - * there is no key to set, so it shows the click label and opens the ⚙ editor (where the click's - * held-item filter lives). - */ - private Button triggerButton(int x, int y, int w, int index, KeybindsConfig.ItemUse use) { - String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; - if (mode.equals(KeybindsConfig.TRIGGER_KEY)) { - return keyButton(x, y, w, use.key, code -> use.key = code); - } - Component label = Component.translatable("keybinds.trigger." + mode); - return Button.builder(label, b -> openEditor(index)) - .bounds(x, y, w, 20) - .build(); - } - - /** Persist the current values and open the per-bind advanced editor for {@code index}. */ - private void openEditor(int index) { - config.save(); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(new BindEditScreen(this, config, index)); - } - } - - /** A full-width bind-key button prefixed with a translated label, for the combat helpers. */ - private Button labelledKeyButton(int x, int y, int w, String labelKey, int currentKey, IntConsumer setter) { - Component label = Component.translatable(labelKey).copy() - .append(Component.literal(": ")).append(keyLabel(currentKey)); - return Button.builder(label, b -> beginCapture(setter)) - .bounds(x, y, w, 20) - .build(); - } - - /** Start listening for the next key press, dropping widget focus so it isn't typed into a box. */ - private void beginCapture(IntConsumer setter) { - this.setFocused(null); - this.keyCapture = setter; - } - - private Component keyLabel(int key) { - if (!KeybindsConfig.isBound(key)) { - return Component.translatable("keybinds.config.unbound"); - } - return InputConstants.Type.KEYSYM.getOrCreate(key).getDisplayName(); - } - - /** Opaque so the text is visible: EditBox treats the color as ARGB, and a 0x00 alpha renders invisibly. */ - private int itemColor(String text) { - return (text == null || text.isBlank() || KeybindsConfig.resolveItem(text) != null) ? 0xFFFFFFFF : 0xFFFF5555; - } - - /** Item ids plus, for vanilla items, their bare paths (a bare path resolves via the default namespace). */ - private static List completions() { - if (completions == null) { - List list = new ArrayList<>(); - for (Identifier id : BuiltInRegistries.ITEM.keySet()) { - list.add(id.toString()); - if (id.getNamespace().equals("minecraft")) { - list.add(id.getPath()); - } - } - list.sort(null); - completions = list; - } - return completions; - } - - /** - * The remaining characters of the first item id that starts with {@code text}, for the EditBox's - * ghost suggestion. Matching is case-sensitive (item ids are lowercase) so the completion always - * appends to a valid prefix. Empty when nothing matches or the text is already a full id. - */ - private static String bestCompletionSuffix(String text) { - if (text == null || text.isEmpty()) { - return ""; - } - for (String c : completions()) { - if (c.length() > text.length() && c.startsWith(text)) { - return c.substring(text.length()); - } - } - return ""; - } - - @Override - public boolean keyPressed(net.minecraft.client.input.KeyEvent event) { - if (keyCapture != null) { - // Escape clears the bind; any other key becomes the new binding. - int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? KeybindsConfig.UNBOUND : event.key(); - keyCapture.accept(assigned); - keyCapture = null; - rebuildWidgets(); - return true; - } - // Tab accepts the autocomplete suggestion in a focused item box. - if (event.key() == GLFW.GLFW_KEY_TAB && getFocused() instanceof EditBox box) { - String suffix = bestCompletionSuffix(box.getValue()); - if (!suffix.isEmpty()) { - box.setValue(box.getValue() + suffix); - return true; - } - } - return super.keyPressed(event); - } - - @Override - public void onClose() { - // Drop fully-blank rows so the file stays tidy, then persist. - config.itemUses.removeIf(u -> u.item == null || u.item.isBlank()); - config.save(); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(parent); - } - } -} diff --git a/src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java b/src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java deleted file mode 100644 index 26c6d1b..0000000 --- a/src/main/java/com/kingjayan/keybinds/LungeSwapScreen.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.kingjayan.keybinds; - -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.components.StringWidget; -import net.minecraft.client.gui.components.Tooltip; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -/** - * Advanced editor for the Lunge Swap combat helper, opened from the ⚙ next to its bind key on - * {@link KeybindsConfigScreen}. The key and the "Vanilla" toggle live on that main row; everything else - * lives here so the main list stays compact: the two extra click triggers, the held-item filter, the - * spear item ids to swap to, and the enchantment id used to rank them by level. Each control carries a - * small {@code ?} help button with a tooltip. Changes write straight through to the shared config; - * {@code Done} persists and returns. - */ -public class LungeSwapScreen extends Screen { - - private static final int COLUMN_WIDTH = 320; - private static final int ROW = 24; - private static final int GAP = 4; - private static final int HELP = 20; - - private final Screen parent; - private final KeybindsConfig config; - - private int left; - - public LungeSwapScreen(Screen parent, KeybindsConfig config) { - super(Component.translatable("keybinds.lunge.title")); - this.parent = parent; - this.config = config; - } - - @Override - protected void init() { - left = this.width / 2 - COLUMN_WIDTH / 2; - int contentWidth = COLUMN_WIDTH - HELP - GAP; - int y = 28; - - addRenderableWidget(new StringWidget(0, 8, this.width, 16, this.title, this.font)); - - // --- Extra click triggers ------------------------------------------------------------- - row(y, CycleButton.onOffBuilder(config.lungeSwapLeftClick) - .create(left, y, contentWidth, 20, Component.translatable("keybinds.lunge.left_click"), - (b, v) -> config.lungeSwapLeftClick = v), "keybinds.lunge.left_click_help"); - y += ROW; - - row(y, CycleButton.onOffBuilder(config.lungeSwapRightClick) - .create(left, y, contentWidth, 20, Component.translatable("keybinds.lunge.right_click"), - (b, v) -> config.lungeSwapRightClick = v), "keybinds.lunge.right_click_help"); - y += ROW; - - // --- Held-item filter ----------------------------------------------------------------- - EditBox held = listBox(y, "keybinds.lunge.held", "keybinds.lunge.held_hint", - config.lungeSwapHeldItems, ids -> config.lungeSwapHeldItems = ids); - row(y, held, "keybinds.lunge.held_help"); - y += ROW; - - // --- Spear ids ------------------------------------------------------------------------ - EditBox spears = listBox(y, "keybinds.lunge.spears", "keybinds.lunge.spears_hint", - config.spearItems, ids -> config.spearItems = ids); - row(y, spears, "keybinds.lunge.spears_help"); - y += ROW; - - // --- Lunge enchantment id ------------------------------------------------------------- - EditBox enchant = new EditBox(this.font, left, y, contentWidth, 20, - Component.translatable("keybinds.lunge.enchant")); - enchant.setMaxLength(128); - enchant.setValue(config.lungeEnchant == null ? "" : config.lungeEnchant); - enchant.setResponder(t -> config.lungeEnchant = t); - row(y, enchant, "keybinds.lunge.enchant_help"); - y += ROW; - - addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(this.width / 2 - 100, this.height - 28, 200, 20) - .build()); - } - - /** A comma-separated item-id list box that writes back a trimmed, blank-free list of ids. */ - private EditBox listBox(int y, String labelKey, String hintKey, List current, - Consumer> setter) { - EditBox box = new EditBox(this.font, left, y, COLUMN_WIDTH - HELP - GAP, 20, - Component.translatable(labelKey)); - box.setMaxLength(512); - box.setValue(String.join(", ", current)); - box.setHint(Component.translatable(hintKey)); - box.setResponder(t -> { - List ids = new ArrayList<>(); - for (String part : t.split(",")) { - String id = part.trim(); - if (!id.isBlank()) { - ids.add(id); - } - } - setter.accept(ids); - }); - return box; - } - - /** Add a control plus a trailing {@code ?} help button sharing its row. */ - private void row(int y, AbstractWidget control, String helpKey) { - addRenderableWidget(control); - Button help = Button.builder(Component.literal("?"), b -> {}) - .bounds(left + COLUMN_WIDTH - HELP, y, HELP, 20) - .build(); - help.setTooltip(Tooltip.create(Component.translatable(helpKey))); - addRenderableWidget(help); - } - - @Override - public void onClose() { - config.save(); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(parent); - } - } -} diff --git a/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java index b43941f..3c786d2 100644 --- a/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java +++ b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java @@ -1,18 +1,18 @@ package com.kingjayan.keybinds; -import com.kingjayan.keybinds.ui.MacroManagerScreen; +import com.kingjayan.keybinds.ui.editor.MacroListScreen; import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; /** * Mod Menu entrypoint. Wires the "config" button next to this mod in the Mods list to the - * {@link MacroManagerScreen} — the same screen Right Shift opens in-game. This class is only loaded + * {@link MacroListScreen} — the same screen Right Shift opens in-game. This class is only loaded * when Mod Menu is present, so Mod Menu is an optional dependency — the mod runs fine without it. */ public class ModMenuIntegration implements ModMenuApi { @Override public ConfigScreenFactory getModConfigScreenFactory() { - return MacroManagerScreen::new; + return MacroListScreen::new; } } diff --git a/src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java b/src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java deleted file mode 100644 index 27f2cfe..0000000 --- a/src/main/java/com/kingjayan/keybinds/compat/ConfigMacros.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.kingjayan.keybinds.compat; - -import com.kingjayan.keybinds.KeybindsConfig; -import com.kingjayan.keybinds.core.action.Action; -import com.kingjayan.keybinds.core.action.AttackAction; -import com.kingjayan.keybinds.core.action.RequireItemAction; -import com.kingjayan.keybinds.core.action.RestoreSlotAction; -import com.kingjayan.keybinds.core.action.SelectItemAction; -import com.kingjayan.keybinds.core.action.ShieldTotemSwapAction; -import com.kingjayan.keybinds.core.action.SwapBackAction; -import com.kingjayan.keybinds.core.action.SwapToItemAction; -import com.kingjayan.keybinds.core.action.UseItemAction; -import com.kingjayan.keybinds.core.action.WaitAction; -import com.kingjayan.keybinds.core.macro.Macro; -import com.kingjayan.keybinds.core.trigger.AllOfTrigger; -import com.kingjayan.keybinds.core.trigger.AnyOfTrigger; -import com.kingjayan.keybinds.core.trigger.ConditionTrigger; -import com.kingjayan.keybinds.core.trigger.HoldingTrigger; -import com.kingjayan.keybinds.core.trigger.KeyTrigger; -import com.kingjayan.keybinds.core.trigger.MouseButtonTrigger; -import com.kingjayan.keybinds.core.trigger.NotTrigger; -import com.kingjayan.keybinds.core.trigger.Trigger; -import net.minecraft.tags.ItemTags; -import org.lwjgl.glfw.GLFW; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -public final class ConfigMacros { - - private static final int LEGACY_SCHEDULER_LATENCY = 1; - - private ConfigMacros() { - } - - public static List build(KeybindsConfig config) { - List macros = new ArrayList<>(); - int count = Math.min(config.itemUses.size(), KeybindsConfig.MAX_ITEM_USES); - for (int i = 0; i < count; i++) { - addIfPresent(macros, itemUseMacro(config.itemUses.get(i), i)); - } - addIfPresent(macros, shieldTotemMacro(config)); - addIfPresent(macros, maceStunSlamMacro(config)); - addIfPresent(macros, lungeSwapMacro(config)); - return macros; - } - - private static void addIfPresent(List macros, Macro macro) { - if (macro != null) { - macros.add(macro); - } - } - - private static Macro itemUseMacro(KeybindsConfig.ItemUse use, int index) { - String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode; - - Trigger primary = null; - if (mode.equals(KeybindsConfig.TRIGGER_KEY)) { - if (!use.hold && KeybindsConfig.isBound(use.key)) { - primary = new KeyTrigger(use.key); - } - } else { - boolean rightClick = mode.equals(KeybindsConfig.TRIGGER_RIGHT_CLICK); - int button = rightClick ? GLFW.GLFW_MOUSE_BUTTON_RIGHT : GLFW.GLFW_MOUSE_BUTTON_LEFT; - List parts = new ArrayList<>(); - parts.add(new MouseButtonTrigger(button)); - if (use.clickItems != null && !use.clickItems.isEmpty()) { - parts.add(new HoldingTrigger(List.copyOf(use.clickItems))); - } - if (rightClick && use.item != null && !use.item.isBlank()) { - parts.add(new NotTrigger(new HoldingTrigger(List.of(use.item)))); - } - primary = parts.size() == 1 ? parts.get(0) : new AllOfTrigger(parts); - } - - List sources = new ArrayList<>(); - if (primary != null) { - sources.add(primary); - } - if (use.conditions != null) { - for (String condition : use.conditions) { - sources.add(new ConditionTrigger(condition)); - } - } - if (sources.isEmpty()) { - return null; - } - Trigger trigger = sources.size() == 1 ? sources.get(0) : new AnyOfTrigger(sources); - - Action action = new UseItemAction(use.item, use.hotbarGate, use.vanillaSwap, - use.swapToDelay, use.useDelay, use.swapBackDelay); - return new Macro(UUID.randomUUID(), "Use item " + (index + 1), true, trigger, List.of(action)); - } - - private static Macro shieldTotemMacro(KeybindsConfig config) { - if (!KeybindsConfig.isBound(config.shieldTotemKey)) { - return null; - } - List actions = new ArrayList<>(); - if (config.shieldTotemVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); - } - actions.add(new ShieldTotemSwapAction()); - return new Macro(UUID.randomUUID(), "Shield/totem swap", true, - new KeyTrigger(config.shieldTotemKey), actions); - } - - private static Macro maceStunSlamMacro(KeybindsConfig config) { - if (!KeybindsConfig.isBound(config.maceStunSlamKey)) { - return null; - } - String axesTag = ItemTags.AXES.location().toString(); - List mace = List.of("minecraft:mace"); - - List actions = new ArrayList<>(); - actions.add(new RequireItemAction(List.of(), axesTag)); - actions.add(new RequireItemAction(mace, "")); - if (config.maceStunSlamVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); - actions.add(new SelectItemAction(List.of(), axesTag)); - actions.add(new AttackAction(false)); - actions.add(new WaitAction(config.combatUseDelay)); - actions.add(new SelectItemAction(mace, "")); - actions.add(new AttackAction(false)); - if (config.returnToStartSlotAfterSlam) { - actions.add(new WaitAction(config.combatSwapBackDelay)); - actions.add(new RestoreSlotAction()); - } - } else { - actions.add(new SelectItemAction(List.of(), axesTag)); - actions.add(new AttackAction(false)); - actions.add(new WaitAction(1)); - actions.add(new SelectItemAction(mace, "")); - actions.add(new AttackAction(false)); - if (config.returnToStartSlotAfterSlam) { - actions.add(new RestoreSlotAction()); - } - } - return new Macro(UUID.randomUUID(), "Mace stun slam", true, - new KeyTrigger(config.maceStunSlamKey), actions); - } - - private static Macro lungeSwapMacro(KeybindsConfig config) { - List sources = new ArrayList<>(); - if (KeybindsConfig.isBound(config.lungeSwapKey)) { - sources.add(new KeyTrigger(config.lungeSwapKey)); - } - if (config.lungeSwapLeftClick) { - sources.add(new MouseButtonTrigger(GLFW.GLFW_MOUSE_BUTTON_LEFT)); - } - if (config.lungeSwapRightClick) { - sources.add(new MouseButtonTrigger(GLFW.GLFW_MOUSE_BUTTON_RIGHT)); - } - if (sources.isEmpty()) { - return null; - } - Trigger trigger = sources.size() == 1 ? sources.get(0) : new AnyOfTrigger(sources); - if (config.lungeSwapHeldItems != null && !config.lungeSwapHeldItems.isEmpty()) { - trigger = new AllOfTrigger(List.of(trigger, new HoldingTrigger(List.copyOf(config.lungeSwapHeldItems)))); - } - - List spears = config.spearItems == null ? List.of() : List.copyOf(config.spearItems); - List actions = new ArrayList<>(); - if (config.lungeSwapVanilla) { - actions.add(new WaitAction(config.combatSwapToDelay + LEGACY_SCHEDULER_LATENCY)); - actions.add(new SwapToItemAction(spears, config.lungeEnchant)); - actions.add(new WaitAction(config.combatUseDelay)); - actions.add(new AttackAction(true)); - actions.add(new WaitAction(config.combatSwapBackDelay)); - actions.add(new SwapBackAction()); - } else { - actions.add(new SwapToItemAction(spears, config.lungeEnchant)); - actions.add(new AttackAction(true)); - actions.add(new SwapBackAction()); - } - return new Macro(UUID.randomUUID(), "Lunge swap", true, trigger, actions); - } -} diff --git a/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java index d2fe8e6..14bf285 100644 --- a/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java +++ b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java @@ -45,6 +45,10 @@ public void replaceAll(List replacement) { revision++; } + public void touch() { + revision++; + } + public int revision() { return revision; } diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java index 722376f..2eb8cb4 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java @@ -19,13 +19,12 @@ public final class TriggerIndex { private final List polled = new ArrayList<>(); private final Map order = new IdentityHashMap<>(); - public void rebuild(List first, List second) { + public void rebuild(List macros) { byKey.clear(); byMouseButton.clear(); polled.clear(); order.clear(); - index(first); - index(second); + index(macros); } public List candidates(TriggerContext ctx) { diff --git a/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java index 0eea231..4496d65 100644 --- a/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java +++ b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java @@ -55,7 +55,7 @@ public static List read(JsonObject root) { return macros; } - private static JsonObject writeMacro(Macro macro) { + public static JsonObject writeMacro(Macro macro) { JsonObject json = new JsonObject(); json.addProperty("id", macro.id().toString()); json.addProperty("name", macro.name()); @@ -71,7 +71,7 @@ private static JsonObject writeMacro(Macro macro) { return json; } - private static Macro readMacro(JsonObject json) { + public static Macro readMacro(JsonObject json) { UUID id = parseId(GsonHelper.getAsString(json, "id", "")); String name = GsonHelper.getAsString(json, "name", "Unnamed"); boolean enabled = GsonHelper.getAsBoolean(json, "enabled", true); diff --git a/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java b/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java deleted file mode 100644 index 7de3045..0000000 --- a/src/main/java/com/kingjayan/keybinds/ui/MacroManagerScreen.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.kingjayan.keybinds.ui; - -import com.kingjayan.keybinds.KeybindsConfigScreen; -import com.kingjayan.keybinds.core.macro.Macro; -import com.kingjayan.keybinds.core.macro.MacroRegistry; -import com.kingjayan.keybinds.persist.MacroStorage; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; -import net.minecraft.client.gui.components.StringWidget; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; - -import java.util.List; - -public class MacroManagerScreen extends Screen { - - private static final int COLUMN_WIDTH = 320; - private static final int ROW_HEIGHT = 24; - private static final int ROWS_PER_PAGE = 7; - - private static final int NAME_WIDTH = 200; - private static final int TOGGLE_WIDTH = 68; - private static final int REMOVE_WIDTH = 20; - private static final int GAP = 4; - - private final Screen parent; - - private int page = 0; - - public MacroManagerScreen(Screen parent) { - super(Component.translatable("keybinds.macros.title")); - this.parent = parent; - } - - @Override - protected void init() { - int left = this.width / 2 - COLUMN_WIDTH / 2; - int y = 24; - - addRenderableWidget(new StringWidget(0, 8, this.width, 20, this.title, this.font)); - - List macros = MacroRegistry.get().all(); - if (macros.isEmpty()) { - addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, - Component.translatable("keybinds.macros.empty"), this.font)); - y += 16; - } else { - int totalPages = Math.max(1, (macros.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE); - page = Math.max(0, Math.min(page, totalPages - 1)); - - int first = page * ROWS_PER_PAGE; - int last = Math.min(first + ROWS_PER_PAGE, macros.size()); - for (int i = first; i < last; i++) { - addMacroRow(left, y, macros.get(i)); - y += ROW_HEIGHT; - } - - if (totalPages > 1) { - Button prev = Button.builder(Component.literal("◄"), b -> { - page--; - rebuildWidgets(); - }) - .bounds(left, y, 32, 20) - .build(); - prev.active = page > 0; - addRenderableWidget(prev); - - Button next = Button.builder(Component.literal("►"), b -> { - page++; - rebuildWidgets(); - }) - .bounds(left + 36, y, 32, 20) - .build(); - next.active = page < totalPages - 1; - addRenderableWidget(next); - y += ROW_HEIGHT; - } - } - - y += 8; - addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12, - Component.translatable("keybinds.macros.note"), this.font)); - - addRenderableWidget(Button.builder(Component.translatable("keybinds.macros.settings"), - b -> openSettings()) - .bounds(this.width / 2 - 154, this.height - 28, 150, 20) - .build()); - addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(this.width / 2 + 4, this.height - 28, 150, 20) - .build()); - } - - private void addMacroRow(int left, int y, Macro macro) { - int x = left; - - addRenderableWidget(new StringWidget(x, y + 4, NAME_WIDTH, 12, - Component.literal(macro.name()), this.font)); - x += NAME_WIDTH + GAP; - - addRenderableWidget(CycleButton.onOffBuilder(macro.enabled()) - .displayOnlyValue() - .create(x, y, TOGGLE_WIDTH, 20, Component.empty(), - (button, value) -> macro.setEnabled(value))); - x += TOGGLE_WIDTH + GAP; - - addRenderableWidget(Button.builder(Component.literal("✕"), b -> { - MacroRegistry.get().remove(macro); - rebuildWidgets(); - }) - .bounds(x, y, REMOVE_WIDTH, 20) - .build()); - } - - private void openSettings() { - MacroStorage.save(MacroRegistry.get().all()); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(new KeybindsConfigScreen(this)); - } - } - - @Override - public void onClose() { - MacroStorage.save(MacroRegistry.get().all()); - if (this.minecraft != null) { - this.minecraft.setScreenAndShow(parent); - } - } -} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java new file mode 100644 index 0000000..7937291 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java @@ -0,0 +1,223 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.util.GsonHelper; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; + +public final class EditorSpecs { + + public enum Kind { + INT, BOOL, STRING, STRING_LIST, ENUM, KEY, MOUSE + } + + public record ParamSpec(String key, String label, Kind kind, String def, List options) { + + static ParamSpec of(String key, String label, Kind kind, String def) { + return new ParamSpec(key, label, kind, def, List.of()); + } + + static ParamSpec choice(String key, String label, String def, List options) { + return new ParamSpec(key, label, Kind.ENUM, def, options); + } + } + + public enum Nesting { + NONE, ONE, MANY + } + + public record TypeSpec(String id, String label, Nesting nesting, List params) { + } + + private static final Map ACTIONS = new LinkedHashMap<>(); + private static final Map TRIGGERS = new LinkedHashMap<>(); + + static { + action("wait", "Wait", ParamSpec.of("ticks", "Ticks", Kind.INT, "1")); + action("select_slot", "Select hotbar slot", ParamSpec.of("slot", "Slot (0-8)", Kind.INT, "0")); + action("restore_slot", "Restore starting slot"); + action("select_item", "Select item on hotbar", + ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""), + ParamSpec.of("tag", "Item tag", Kind.STRING, "")); + action("require_item", "Require item in inventory", + ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""), + ParamSpec.of("tag", "Item tag", Kind.STRING, "")); + action("swap_to_item", "Swap to item", + ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""), + ParamSpec.of("rankEnchant", "Rank by enchant", Kind.STRING, "")); + action("swap_back", "Swap back"); + action("attack", "Attack", ParamSpec.of("swing", "Swing arm", Kind.BOOL, "false")); + action("use", "Use held item"); + action("use_item", "Use specific item", + ParamSpec.of("item", "Item id", Kind.STRING, ""), + ParamSpec.choice("hotbarGate", "Only fire when", "any", List.of("any", "in_hotbar", "not_in_hotbar")), + ParamSpec.of("vanillaSwap", "Vanilla swap", Kind.BOOL, "false"), + ParamSpec.of("swapToDelay", "Swap-to ticks", Kind.INT, "2"), + ParamSpec.of("useDelay", "Use ticks", Kind.INT, "3"), + ParamSpec.of("swapBackDelay", "Swap-back ticks", Kind.INT, "2")); + action("shield_totem_swap", "Shield/totem offhand swap"); + + trigger("key", "Key press", Nesting.NONE, + List.of(ParamSpec.of("key", "Key", Kind.KEY, "-1"))); + trigger("key_release", "Key release", Nesting.NONE, + List.of(ParamSpec.of("key", "Key", Kind.KEY, "-1"))); + trigger("mouse_button", "Mouse press", Nesting.NONE, + List.of(ParamSpec.of("button", "Button", Kind.MOUSE, "-1"))); + trigger("mouse_release", "Mouse release", Nesting.NONE, + List.of(ParamSpec.of("button", "Button", Kind.MOUSE, "-1"))); + trigger("condition", "Player condition", Nesting.NONE, + List.of(ParamSpec.choice("condition", "Condition", "low_health", PlayerConditions.ids()))); + trigger("holding", "Holding item", Nesting.NONE, + List.of(ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""))); + trigger("slot_selected", "Hotbar slot selected", Nesting.NONE, + List.of(ParamSpec.of("slot", "Slot (0-8)", Kind.INT, "0"))); + trigger("riding", "Riding a mount", Nesting.NONE, List.of()); + trigger("held_durability", "Held durability at most", Nesting.NONE, + List.of(ParamSpec.of("durability", "Durability", Kind.INT, "10"))); + trigger("all_of", "All of (AND)", Nesting.MANY, List.of()); + trigger("any_of", "Any of (OR)", Nesting.MANY, List.of()); + trigger("not", "Not", Nesting.ONE, List.of()); + } + + private EditorSpecs() { + } + + private static void action(String id, String label, ParamSpec... params) { + ACTIONS.put(id, new TypeSpec(id, label, Nesting.NONE, List.of(params))); + } + + private static void trigger(String id, String label, Nesting nesting, List params) { + TRIGGERS.put(id, new TypeSpec(id, label, nesting, params)); + } + + public static List actionTypes() { + return List.copyOf(ACTIONS.values()); + } + + public static List triggerTypes() { + return List.copyOf(TRIGGERS.values()); + } + + public static TypeSpec actionSpec(String id) { + return ACTIONS.get(id); + } + + public static TypeSpec triggerSpec(String id) { + return TRIGGERS.get(id); + } + + public static JsonObject defaultJson(TypeSpec spec) { + JsonObject json = new JsonObject(); + json.addProperty("type", spec.id()); + for (ParamSpec param : spec.params()) { + switch (param.kind()) { + case INT, KEY, MOUSE -> json.addProperty(param.key(), Integer.parseInt(param.def())); + case BOOL -> json.addProperty(param.key(), Boolean.parseBoolean(param.def())); + case STRING, ENUM -> json.addProperty(param.key(), param.def()); + case STRING_LIST -> json.add(param.key(), new JsonArray()); + } + } + if (spec.nesting() == Nesting.MANY) { + json.add("children", new JsonArray()); + } + return json; + } + + public static String describeAction(JsonObject json) { + String type = GsonHelper.getAsString(json, "type", ""); + return switch (type) { + case "wait" -> "Wait " + GsonHelper.getAsInt(json, "ticks", 1) + " ticks"; + case "select_slot" -> "Select slot " + (GsonHelper.getAsInt(json, "slot", 0) + 1); + case "restore_slot" -> "Restore starting slot"; + case "select_item" -> "Select " + itemsOrTag(json); + case "require_item" -> "Require " + itemsOrTag(json); + case "swap_to_item" -> "Swap to " + itemsOrTag(json); + case "swap_back" -> "Swap back"; + case "attack" -> GsonHelper.getAsBoolean(json, "swing", false) ? "Attack (swing)" : "Attack"; + case "use" -> "Use held item"; + case "use_item" -> "Use " + orAny(GsonHelper.getAsString(json, "item", "")); + case "shield_totem_swap" -> "Shield/totem offhand swap"; + default -> type; + }; + } + + public static String describeTrigger(JsonObject json) { + String type = GsonHelper.getAsString(json, "type", ""); + return switch (type) { + case "key" -> "Key press: " + keyName(GsonHelper.getAsInt(json, "key", -1)); + case "key_release" -> "Key release: " + keyName(GsonHelper.getAsInt(json, "key", -1)); + case "mouse_button" -> "Mouse press: " + buttonName(GsonHelper.getAsInt(json, "button", -1)); + case "mouse_release" -> "Mouse release: " + buttonName(GsonHelper.getAsInt(json, "button", -1)); + case "condition" -> "Condition: " + GsonHelper.getAsString(json, "condition", ""); + case "holding" -> "Holding " + orAny(joinList(json, "items")); + case "slot_selected" -> "Slot " + (GsonHelper.getAsInt(json, "slot", 0) + 1) + " selected"; + case "riding" -> "Riding a mount"; + case "held_durability" -> "Held durability ≤ " + GsonHelper.getAsInt(json, "durability", 0); + case "all_of" -> "All of (AND)"; + case "any_of" -> "Any of (OR)"; + case "not" -> "Not"; + default -> type; + }; + } + + public static String keyName(int key) { + if (key < 0) { + return "Unbound"; + } + return InputConstants.Type.KEYSYM.getOrCreate(key).getDisplayName().getString(); + } + + public static String buttonName(int button) { + if (button < 0) { + return "Unbound"; + } + return switch (button) { + case GLFW.GLFW_MOUSE_BUTTON_LEFT -> "Left click"; + case GLFW.GLFW_MOUSE_BUTTON_RIGHT -> "Right click"; + case GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> "Middle click"; + default -> "Button " + button; + }; + } + + private static String itemsOrTag(JsonObject json) { + String items = joinList(json, "items"); + if (!items.isEmpty()) { + return items; + } + String tag = GsonHelper.getAsString(json, "tag", ""); + return tag.isEmpty() ? "anything" : "#" + tag; + } + + private static String orAny(String value) { + return value.isEmpty() ? "anything" : value; + } + + private static String joinList(JsonObject json, String field) { + StringJoiner joiner = new StringJoiner(", "); + for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) { + if (element.isJsonPrimitive()) { + joiner.add(element.getAsString()); + } + } + return joiner.toString(); + } + + public static List readStringList(JsonObject json, String field) { + List values = new ArrayList<>(); + for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) { + if (element.isJsonPrimitive()) { + values.add(element.getAsString()); + } + } + return values; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java new file mode 100644 index 0000000..3f0f929 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java @@ -0,0 +1,172 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.macro.MacroRegistry; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.persist.MacroStorage; +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ScrollPanel; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.network.chat.Component; + +import java.util.Collections; +import java.util.List; + +public class MacroEditScreen extends FrameworkScreen { + + private static final int COLUMN_WIDTH = 340; + private static final int ROW_HEIGHT = 24; + + private final MacroListScreen parent; + private final Macro macro; + + private ScrollPanel actionsPanel; + private StringWidget triggerSummary; + + public MacroEditScreen(MacroListScreen parent, Macro macro) { + super(Component.translatable("keybinds.editor.macro_title")); + this.parent = parent; + this.macro = macro; + } + + @Override + protected void init() { + int left = width / 2 - COLUMN_WIDTH / 2; + addRenderableWidget(new StringWidget(0, 8, width, 20, title, font)); + + EditBox nameBox = new EditBox(font, left, 24, COLUMN_WIDTH - 52, 20, + Component.translatable("keybinds.editor.name")); + nameBox.setMaxLength(64); + nameBox.setValue(macro.name()); + nameBox.setResponder(value -> macro.setName(value.isBlank() ? "Unnamed" : value)); + addRenderableWidget(nameBox); + addRenderableWidget(CycleButton.onOffBuilder(macro.enabled()) + .displayOnlyValue() + .create(left + COLUMN_WIDTH - 44, 24, 44, 20, Component.empty(), + (b, value) -> macro.setEnabled(value))); + + triggerSummary = addRenderableWidget(new StringWidget(left, 54, COLUMN_WIDTH - 92, 12, + triggerText(), font)); + addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.edit_trigger"), b -> { + if (minecraft != null) { + minecraft.setScreenAndShow(new TriggerEditScreen(this, macro)); + } + }) + .bounds(left + COLUMN_WIDTH - 88, 50, 88, 20) + .build()); + + addRenderableWidget(new StringWidget(left, 76, COLUMN_WIDTH, 12, + Component.translatable("keybinds.editor.actions"), font)); + actionsPanel = addRenderableWidget(new ScrollPanel(left, 90, COLUMN_WIDTH, height - 152)); + fillActions(); + + addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.add_action"), b -> addAction()) + .bounds(width / 2 - 154, height - 28, 150, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 28, 150, 20) + .build()); + } + + private Component triggerText() { + String text = macro.trigger() == null ? "No trigger" + : EditorSpecs.describeTrigger(TriggerRegistry.toJson(macro.trigger())); + return Component.literal("Trigger: " + text); + } + + private void fillActions() { + actionsPanel.clearEntries(); + List actions = macro.actions(); + if (actions.isEmpty()) { + actionsPanel.add(new StringWidget(0, 4, COLUMN_WIDTH - 8, 12, + Component.translatable("keybinds.editor.no_actions"), font), 0, 0); + return; + } + int y = 0; + for (int i = 0; i < actions.size(); i++) { + addActionRow(i, y); + y += ROW_HEIGHT; + } + } + + private void addActionRow(int index, int y) { + JsonObject json = ActionRegistry.toJson(macro.actions().get(index)); + actionsPanel.add(Button.builder( + Component.literal((index + 1) + ". " + EditorSpecs.describeAction(json)), + b -> editAction(index)) + .bounds(0, 0, COLUMN_WIDTH - 84, 20) + .build(), 0, y); + Button up = Button.builder(Component.literal("↑"), b -> moveAction(index, -1)) + .bounds(0, 0, 20, 20) + .build(); + up.active = index > 0; + actionsPanel.add(up, COLUMN_WIDTH - 80, y); + Button down = Button.builder(Component.literal("↓"), b -> moveAction(index, 1)) + .bounds(0, 0, 20, 20) + .build(); + down.active = index < macro.actions().size() - 1; + actionsPanel.add(down, COLUMN_WIDTH - 56, y); + actionsPanel.add(Button.builder(Component.literal("✕"), b -> { + macro.actions().remove(index); + fillActions(); + }) + .bounds(0, 0, 20, 20) + .build(), COLUMN_WIDTH - 32, y); + } + + private void moveAction(int index, int delta) { + int target = index + delta; + if (target < 0 || target >= macro.actions().size()) { + return; + } + Collections.swap(macro.actions(), index, target); + fillActions(); + } + + private void addAction() { + pushModal(new PaletteModal(this, font, "Add action", EditorSpecs.actionTypes(), type -> { + Action action = ActionRegistry.fromJson(EditorSpecs.defaultJson(type)); + if (action != null) { + macro.actions().add(action); + fillActions(); + if (!type.params().isEmpty()) { + editAction(macro.actions().size() - 1); + } + } + })); + } + + private void editAction(int index) { + JsonObject json = ActionRegistry.toJson(macro.actions().get(index)); + EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(json.get("type").getAsString()); + if (spec == null || spec.params().isEmpty()) { + return; + } + pushModal(new ParamModal(this, font, spec.label(), json.deepCopy(), spec.params(), saved -> { + Action parsed = ActionRegistry.fromJson(saved); + if (parsed != null) { + macro.actions().set(index, parsed); + } + fillActions(); + })); + } + + void refreshTrigger() { + triggerSummary.setMessage(triggerText()); + } + + @Override + public void onClose() { + MacroStorage.save(MacroRegistry.get().all()); + MacroRegistry.get().touch(); + if (minecraft != null) { + minecraft.setScreenAndShow(parent); + } + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java new file mode 100644 index 0000000..3adb3b6 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java @@ -0,0 +1,138 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.macro.MacroRegistry; +import com.kingjayan.keybinds.persist.MacroStorage; +import com.kingjayan.keybinds.serialize.MacroCodec; +import com.kingjayan.keybinds.ui.framework.ContextMenu; +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ScrollPanel; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.List; +import java.util.UUID; + +public class MacroListScreen extends FrameworkScreen { + + private static final int ROW_WIDTH = 320; + private static final int ROW_HEIGHT = 24; + + private final Screen parent; + private ScrollPanel panel; + + public MacroListScreen(Screen parent) { + super(Component.translatable("keybinds.editor.title")); + this.parent = parent; + } + + @Override + protected void init() { + addRenderableWidget(new StringWidget(0, 8, width, 20, title, font)); + + int left = width / 2 - ROW_WIDTH / 2; + panel = addRenderableWidget(new ScrollPanel(left, 28, ROW_WIDTH, height - 96)); + fillPanel(); + + addRenderableWidget(new StringWidget(left, height - 60, ROW_WIDTH, 12, + Component.translatable("keybinds.editor.hint"), font)); + addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.new"), b -> { + Macro macro = Macro.create("New macro"); + MacroRegistry.get().add(macro); + openEditor(macro); + }) + .bounds(width / 2 - 154, height - 28, 150, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 + 4, height - 28, 150, 20) + .build()); + } + + private void fillPanel() { + panel.clearEntries(); + List macros = MacroRegistry.get().all(); + if (macros.isEmpty()) { + panel.add(new StringWidget(0, 4, ROW_WIDTH - 8, 12, + Component.translatable("keybinds.editor.empty"), font), 0, 0); + return; + } + int y = 0; + for (Macro macro : macros) { + addRow(macro, y); + y += ROW_HEIGHT; + } + } + + private void addRow(Macro macro, int y) { + panel.add(CycleButton.onOffBuilder(macro.enabled()) + .displayOnlyValue() + .create(0, 0, 44, 20, Component.empty(), (b, value) -> macro.setEnabled(value)), 0, y); + panel.add(Button.builder(Component.literal(macro.name()), b -> openEditor(macro)) + .bounds(0, 0, ROW_WIDTH - 80, 20) + .build(), 48, y); + panel.add(Button.builder(Component.literal("✕"), b -> { + MacroRegistry.get().remove(macro); + rebuildWidgets(); + }) + .bounds(0, 0, 20, 20) + .build(), ROW_WIDTH - 28, y); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (!hasOverlay() && event.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT + && panel.isMouseOver(event.x(), event.y())) { + Macro macro = macroAt(event.y()); + if (macro != null) { + openContextMenu(new ContextMenu(font, (int) event.x(), (int) event.y(), List.of( + new ContextMenu.Entry(Component.translatable("keybinds.editor.edit"), () -> openEditor(macro)), + new ContextMenu.Entry(Component.translatable("keybinds.editor.duplicate"), () -> duplicate(macro)), + new ContextMenu.Entry(Component.translatable("keybinds.editor.delete"), () -> { + MacroRegistry.get().remove(macro); + rebuildWidgets(); + })))); + return true; + } + } + return super.mouseClicked(event, doubleClick); + } + + private Macro macroAt(double mouseY) { + List macros = MacroRegistry.get().all(); + int index = ((int) mouseY - panel.getY() + (int) panel.scroll()) / ROW_HEIGHT; + return index >= 0 && index < macros.size() ? macros.get(index) : null; + } + + private void duplicate(Macro macro) { + Macro copy = MacroCodec.readMacro(MacroCodec.writeMacro(macro)); + Macro fresh = new Macro(UUID.randomUUID(), copy.name() + " (copy)", copy.enabled(), + copy.trigger(), copy.actions()); + MacroRegistry.get().add(fresh); + rebuildWidgets(); + } + + private void openEditor(Macro macro) { + save(); + if (minecraft != null) { + minecraft.setScreenAndShow(new MacroEditScreen(this, macro)); + } + } + + private void save() { + MacroStorage.save(MacroRegistry.get().all()); + MacroRegistry.get().touch(); + } + + @Override + public void onClose() { + save(); + if (minecraft != null) { + minecraft.setScreenAndShow(parent); + } + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java b/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java new file mode 100644 index 0000000..9f81ab5 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java @@ -0,0 +1,78 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ModalOverlay; +import com.kingjayan.keybinds.ui.framework.ScrollPanel; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.network.chat.Component; + +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +public class PaletteModal extends ModalOverlay { + + private static final int PANEL_WIDTH = 240; + private static final int LIST_HEIGHT = 132; + private static final int PANEL_BACKGROUND = 0xF0101018; + private static final int PANEL_BORDER = 0xFF5000A0; + + private final List types; + private final Consumer onPick; + private final ScrollPanel list; + private final int panelLeft; + private final int panelTop; + private final int panelHeight; + + public PaletteModal(FrameworkScreen host, Font font, String title, + List types, Consumer onPick) { + super(host, null); + this.types = types; + this.onPick = onPick; + this.panelHeight = 20 + 24 + LIST_HEIGHT + 32; + this.panelLeft = (width - PANEL_WIDTH) / 2; + this.panelTop = (height - panelHeight) / 2; + + add(new StringWidget(panelLeft, panelTop + 4, PANEL_WIDTH, 12, Component.literal(title), font)); + EditBox search = new EditBox(font, panelLeft + 8, panelTop + 20, PANEL_WIDTH - 16, 20, + Component.literal("Search")); + search.setResponder(this::refill); + add(search); + list = add(new ScrollPanel(panelLeft + 8, panelTop + 48, PANEL_WIDTH - 16, LIST_HEIGHT)); + refill(""); + add(Button.builder(Component.translatable("gui.cancel"), b -> dismiss()) + .bounds(panelLeft + PANEL_WIDTH / 2 - 50, panelTop + panelHeight - 26, 100, 20) + .build()); + } + + private void refill(String filter) { + String query = filter.trim().toLowerCase(Locale.ROOT); + list.clearEntries(); + int y = 0; + for (EditorSpecs.TypeSpec type : types) { + if (!query.isEmpty() && !type.label().toLowerCase(Locale.ROOT).contains(query) + && !type.id().contains(query)) { + continue; + } + list.add(Button.builder(Component.literal(type.label()), b -> { + dismiss(); + onPick.accept(type); + }) + .bounds(0, 0, list.getWidth() - 8, 20) + .build(), 0, y); + y += 22; + } + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.fill(0, 0, width, height, 0x90000000); + graphics.fill(panelLeft - 1, panelTop - 1, panelLeft + PANEL_WIDTH + 1, panelTop + panelHeight + 1, PANEL_BORDER); + graphics.fill(panelLeft, panelTop, panelLeft + PANEL_WIDTH, panelTop + panelHeight, PANEL_BACKGROUND); + super.extractWidgetRenderState(graphics, mouseX, mouseY, a); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java new file mode 100644 index 0000000..ed06686 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java @@ -0,0 +1,203 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ModalOverlay; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.util.GsonHelper; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public class ParamModal extends ModalOverlay { + + private static final int PANEL_WIDTH = 280; + private static final int LABEL_WIDTH = 110; + private static final int CONTROL_WIDTH = 150; + private static final int ROW_HEIGHT = 24; + private static final int PANEL_BACKGROUND = 0xF0101018; + private static final int PANEL_BORDER = 0xFF5000A0; + + private final Font font; + private final JsonObject working; + private final List writers = new ArrayList<>(); + private final int panelLeft; + private final int panelTop; + private final int panelHeight; + + private Button capturingButton; + private EditorSpecs.ParamSpec capturingSpec; + + public ParamModal(FrameworkScreen host, Font font, String title, JsonObject working, + List specs, Consumer onSave) { + super(host, null); + this.font = font; + this.working = working; + this.panelHeight = 20 + specs.size() * ROW_HEIGHT + 28; + this.panelLeft = (width - PANEL_WIDTH) / 2; + this.panelTop = (height - panelHeight) / 2; + dismissOnOutsideClick(false); + + add(new StringWidget(panelLeft, panelTop + 4, PANEL_WIDTH, 12, Component.literal(title), font)); + int y = panelTop + 20; + for (EditorSpecs.ParamSpec spec : specs) { + addRow(spec, y); + y += ROW_HEIGHT; + } + int buttonY = y + 4; + add(Button.builder(Component.translatable("gui.done"), b -> { + for (Runnable writer : writers) { + writer.run(); + } + dismiss(); + onSave.accept(working); + }) + .bounds(panelLeft + PANEL_WIDTH / 2 - 102, buttonY, 100, 20) + .build()); + add(Button.builder(Component.translatable("gui.cancel"), b -> dismiss()) + .bounds(panelLeft + PANEL_WIDTH / 2 + 2, buttonY, 100, 20) + .build()); + } + + private void addRow(EditorSpecs.ParamSpec spec, int y) { + add(new StringWidget(panelLeft + 8, y + 5, LABEL_WIDTH, 12, Component.literal(spec.label()), font)); + int x = panelLeft + PANEL_WIDTH - CONTROL_WIDTH - 8; + switch (spec.kind()) { + case INT -> { + EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label())); + box.setValue(String.valueOf(GsonHelper.getAsInt(working, spec.key(), Integer.parseInt(spec.def())))); + add(box); + writers.add(() -> working.addProperty(spec.key(), + parsable(box.getValue()) ? Integer.parseInt(box.getValue()) : Integer.parseInt(spec.def()))); + } + case STRING -> { + EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label())); + box.setMaxLength(256); + box.setValue(GsonHelper.getAsString(working, spec.key(), spec.def())); + add(box); + writers.add(() -> working.addProperty(spec.key(), box.getValue().trim())); + } + case STRING_LIST -> { + EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label())); + box.setMaxLength(512); + box.setValue(String.join(", ", EditorSpecs.readStringList(working, spec.key()))); + add(box); + writers.add(() -> { + JsonArray array = new JsonArray(); + for (String part : box.getValue().split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + array.add(trimmed); + } + } + working.add(spec.key(), array); + }); + } + case BOOL -> { + CycleButton button = CycleButton.onOffBuilder( + GsonHelper.getAsBoolean(working, spec.key(), Boolean.parseBoolean(spec.def()))) + .displayOnlyValue() + .create(x, y, CONTROL_WIDTH, 20, Component.empty(), + (b, value) -> working.addProperty(spec.key(), value)); + add(button); + } + case ENUM -> { + String current = GsonHelper.getAsString(working, spec.key(), spec.def()); + if (!spec.options().contains(current)) { + current = spec.def(); + } + CycleButton button = CycleButton.builder(Component::literal, current) + .withValues(spec.options()) + .displayOnlyValue() + .create(x, y, CONTROL_WIDTH, 20, Component.empty(), + (b, value) -> working.addProperty(spec.key(), value)); + add(button); + } + case KEY, MOUSE -> { + int current = GsonHelper.getAsInt(working, spec.key(), -1); + Button button = Button.builder(captureLabel(spec, current), b -> { + capturingButton = b; + capturingSpec = spec; + b.setMessage(Component.literal("> press <")); + }) + .bounds(x, y, CONTROL_WIDTH, 20) + .build(); + add(button); + } + } + } + + private static Component captureLabel(EditorSpecs.ParamSpec spec, int code) { + return Component.literal(spec.kind() == EditorSpecs.Kind.KEY + ? EditorSpecs.keyName(code) : EditorSpecs.buttonName(code)); + } + + private static boolean parsable(String text) { + try { + Integer.parseInt(text); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (capturingSpec != null && capturingSpec.kind() == EditorSpecs.Kind.KEY) { + int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? -1 : event.key(); + working.addProperty(capturingSpec.key(), assigned); + capturingButton.setMessage(captureLabel(capturingSpec, assigned)); + endCapture(); + return true; + } + if (capturingSpec != null) { + if (event.isEscape()) { + working.addProperty(capturingSpec.key(), -1); + capturingButton.setMessage(captureLabel(capturingSpec, -1)); + } + endCapture(); + return true; + } + return super.keyPressed(event); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (capturingSpec != null) { + if (capturingSpec.kind() == EditorSpecs.Kind.MOUSE) { + working.addProperty(capturingSpec.key(), event.button()); + capturingButton.setMessage(captureLabel(capturingSpec, event.button())); + } else { + capturingButton.setMessage(captureLabel(capturingSpec, + GsonHelper.getAsInt(working, capturingSpec.key(), -1))); + } + endCapture(); + return true; + } + return super.mouseClicked(event, doubleClick); + } + + private void endCapture() { + capturingButton = null; + capturingSpec = null; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.fill(0, 0, width, height, 0x90000000); + graphics.fill(panelLeft - 1, panelTop - 1, panelLeft + PANEL_WIDTH + 1, panelTop + panelHeight + 1, PANEL_BORDER); + graphics.fill(panelLeft, panelTop, panelLeft + PANEL_WIDTH, panelTop + panelHeight, PANEL_BACKGROUND); + super.extractWidgetRenderState(graphics, mouseX, mouseY, a); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java new file mode 100644 index 0000000..6b59942 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java @@ -0,0 +1,145 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ScrollPanel; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.network.chat.Component; +import net.minecraft.util.GsonHelper; + +import java.util.Map; +import java.util.function.Consumer; + +public class TriggerEditScreen extends FrameworkScreen { + + private static final int COLUMN_WIDTH = 340; + private static final int ROW_HEIGHT = 24; + private static final int INDENT = 14; + + private final MacroEditScreen parent; + private final Macro macro; + + private JsonObject root; + private ScrollPanel panel; + private int rowY; + + public TriggerEditScreen(MacroEditScreen parent, Macro macro) { + super(Component.translatable("keybinds.editor.trigger_title")); + this.parent = parent; + this.macro = macro; + this.root = macro.trigger() == null ? null : TriggerRegistry.toJson(macro.trigger()); + } + + @Override + protected void init() { + int left = width / 2 - COLUMN_WIDTH / 2; + addRenderableWidget(new StringWidget(0, 8, width, 20, title, font)); + panel = addRenderableWidget(new ScrollPanel(left, 28, COLUMN_WIDTH, height - 90)); + rowY = 0; + if (root == null) { + panel.add(Button.builder(Component.translatable("keybinds.editor.set_trigger"), + b -> pickType(json -> { + root = json; + rebuildWidgets(); + })) + .bounds(0, 0, 160, 20) + .build(), 0, 0); + } else { + addNodeRows(root, 0, () -> root = null); + } + addRenderableWidget(new StringWidget(left, height - 54, COLUMN_WIDTH, 12, + Component.translatable("keybinds.editor.trigger_hint"), font)); + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(width / 2 - 75, height - 28, 150, 20) + .build()); + } + + private void addNodeRows(JsonObject node, int depth, Runnable remover) { + String type = GsonHelper.getAsString(node, "type", ""); + EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(type); + int indent = depth * INDENT; + int y = rowY; + rowY += ROW_HEIGHT; + + panel.add(new StringWidget(indent, y + 6, COLUMN_WIDTH - indent - 84, 12, + Component.literal(EditorSpecs.describeTrigger(node)), font), indent, y); + + int x = COLUMN_WIDTH - 80; + if (spec != null && !spec.params().isEmpty()) { + panel.add(Button.builder(Component.literal("✎"), b -> editParams(node, spec)) + .bounds(0, 0, 20, 20) + .build(), x, y); + } + x += 24; + if (spec != null && spec.nesting() == EditorSpecs.Nesting.MANY) { + JsonArray children = GsonHelper.getAsJsonArray(node, "children", null); + if (children == null) { + JsonArray created = new JsonArray(); + node.add("children", created); + children = created; + } + JsonArray target = children; + panel.add(Button.builder(Component.literal("+"), b -> pickType(json -> { + target.add(json); + rebuildWidgets(); + })) + .bounds(0, 0, 20, 20) + .build(), x, y); + } else if (spec != null && spec.nesting() == EditorSpecs.Nesting.ONE + && !(node.get("child") instanceof JsonObject)) { + panel.add(Button.builder(Component.literal("+"), b -> pickType(json -> { + node.add("child", json); + rebuildWidgets(); + })) + .bounds(0, 0, 20, 20) + .build(), x, y); + } + x += 24; + panel.add(Button.builder(Component.literal("✕"), b -> { + remover.run(); + rebuildWidgets(); + }) + .bounds(0, 0, 20, 20) + .build(), x, y); + + if (spec != null && spec.nesting() == EditorSpecs.Nesting.MANY) { + JsonArray children = GsonHelper.getAsJsonArray(node, "children", new JsonArray()); + for (int i = 0; i < children.size(); i++) { + if (children.get(i) instanceof JsonObject child) { + int index = i; + addNodeRows(child, depth + 1, () -> children.remove(index)); + } + } + } else if (spec != null && spec.nesting() == EditorSpecs.Nesting.ONE + && node.get("child") instanceof JsonObject child) { + addNodeRows(child, depth + 1, remover); + } + } + + private void pickType(Consumer onCreate) { + pushModal(new PaletteModal(this, font, "Add trigger", EditorSpecs.triggerTypes(), + type -> onCreate.accept(EditorSpecs.defaultJson(type)))); + } + + private void editParams(JsonObject node, EditorSpecs.TypeSpec spec) { + pushModal(new ParamModal(this, font, spec.label(), node.deepCopy(), spec.params(), saved -> { + for (Map.Entry entry : saved.entrySet()) { + node.add(entry.getKey(), entry.getValue()); + } + rebuildWidgets(); + })); + } + + @Override + public void onClose() { + macro.setTrigger(root == null ? null : TriggerRegistry.fromJson(root)); + parent.refreshTrigger(); + if (minecraft != null) { + minecraft.setScreenAndShow(parent); + } + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java index 0e9d95a..2ca4742 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java @@ -16,7 +16,7 @@ public class ModalOverlay extends AbstractContainerWidget { private static final int DIM = 0x90000000; - private final List content = new ArrayList<>(); + protected final List content = new ArrayList<>(); private final FrameworkScreen host; private final Runnable onDismiss; private boolean dismissOnOutsideClick = true; @@ -62,6 +62,16 @@ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { return true; } + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + for (AbstractWidget widget : content) { + if (widget.isMouseOver(x, y) && widget.mouseScrolled(x, y, scrollX, scrollY)) { + return true; + } + } + return true; + } + @Override public boolean keyPressed(KeyEvent event) { if (event.isEscape()) { diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java index b7a9ccb..a714fb7 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java +++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java @@ -29,6 +29,10 @@ public T add(T widget, int relX, int relY) { return widget; } + public double scroll() { + return scrollAmount(); + } + public void clearEntries() { entries.clear(); widgets.clear(); diff --git a/src/main/resources/assets/keybinds/lang/en_us.json b/src/main/resources/assets/keybinds/lang/en_us.json index 6e8ebcd..210a89b 100644 --- a/src/main/resources/assets/keybinds/lang/en_us.json +++ b/src/main/resources/assets/keybinds/lang/en_us.json @@ -1,76 +1,20 @@ { - "keybinds.macros.title": "Macros", - "keybinds.macros.empty": "No macros yet — custom macros live in config/macros.json.", - "keybinds.macros.note": "Toggle or delete macros here. Built-in helpers are configured under Settings.", - "keybinds.macros.settings": "Settings…", + "keybinds.editor.title": "Macros", + "keybinds.editor.empty": "No macros yet — click New Macro to create one.", + "keybinds.editor.hint": "Click a macro to edit it. Right-click a row for more options.", + "keybinds.editor.new": "+ New Macro", + "keybinds.editor.edit": "Edit", + "keybinds.editor.duplicate": "Duplicate", + "keybinds.editor.delete": "Delete", - "keybinds.config.title": "Keybinds Settings", - "keybinds.config.uses_header": "Use Item binds — each right-clicks an item on its own trigger", - "keybinds.config.uses_note": "Type an item (Tab to autocomplete), set a key, or open ⚙ for click/condition triggers & more.", - "keybinds.config.item_id": "Item id", - "keybinds.config.hold": "Hold to use", - "keybinds.config.add_slot": "+ Add slot", - "keybinds.config.unbound": "Unbound", - "keybinds.config.edit_tooltip": "Advanced: extra triggers, hotbar filter, vanilla swap", - "keybinds.config.helpers_header": "Combat helpers", - "keybinds.config.shield_totem": "Swap Shield / Totem", - "keybinds.config.mace_slam": "Mace Stun Slam", - "keybinds.config.lunge_swap": "Lunge Swap", - "keybinds.config.return_slot": "Return to start slot after slam", - "keybinds.config.vanilla": "Vanilla", - "keybinds.config.vanilla_help": "Vanilla mode: do the swap the slower, more human-looking way — spread over a few ticks — instead of an instant same-tick action.", - "keybinds.config.lunge_edit_tooltip": "Lunge Swap options: click triggers, held-item filter, spears, enchantment", + "keybinds.editor.macro_title": "Edit Macro", + "keybinds.editor.name": "Macro name", + "keybinds.editor.actions": "Actions — run in order when the trigger fires", + "keybinds.editor.no_actions": "No actions yet — click Add Action.", + "keybinds.editor.add_action": "+ Add Action", + "keybinds.editor.edit_trigger": "Edit Trigger…", - "keybinds.lunge.title": "Lunge Swap", - "keybinds.lunge.left_click": "Also trigger on left-click", - "keybinds.lunge.left_click_help": "Also lunge when you left-click (on top of your normal attack), while the held-item filter passes.", - "keybinds.lunge.right_click": "Also trigger on right-click", - "keybinds.lunge.right_click_help": "Also lunge when you right-click, while the held-item filter passes.", - "keybinds.lunge.held": "Held-item filter", - "keybinds.lunge.held_hint": "held items that allow a trigger — blank = any", - "keybinds.lunge.held_help": "Comma-separated item ids you must hold for a trigger to fire. Blank = any item. Use minecraft:air to require an empty hand.", - "keybinds.lunge.spears": "Spear items", - "keybinds.lunge.spears_hint": "spear item ids, comma-separated", - "keybinds.lunge.spears_help": "Comma-separated spear item ids to swap to. Among those you carry, the one with the highest lunge level is chosen. Blank = any item that has the lunge enchantment.", - "keybinds.lunge.enchant": "Lunge enchantment id", - "keybinds.lunge.enchant_help": "Enchantment id used to rank spears by level. Default minecraft:lunge.", - - "keybinds.edit.title": "Bind #%s", - "keybinds.edit.triggers": "Trigger — a key OR a click", - "keybinds.edit.hold_help": "Off: one right-click. On: hold the key so multi-tick uses finish (drawing a bow, eating). Hold applies to the key trigger only.", - "keybinds.edit.trigger_mode": "Trigger", - "keybinds.edit.trigger_mode_help": "How this bind fires: a key, or a left/right-click while holding the items below. Pick one — a key and a click are mutually exclusive.", - "keybinds.edit.key": "Key", - "keybinds.edit.key_help": "Click, then press a key to bind it. Esc clears (unbinds).", - "keybinds.edit.click_items": "Click items", - "keybinds.edit.click_items_hint": "held items for the click trigger — blank = any", - "keybinds.edit.click_items_help": "Comma-separated item ids you must be holding for the click to fire. Leave blank to fire while holding anything. Your normal click still happens.", - "keybinds.edit.conditions": "Conditions (also fire it)", - "keybinds.edit.conditions_help": "On top of the trigger above, also fire the moment any ticked condition becomes true (once per onset, not every tick).", - - "keybinds.mode.key": "Key", - "keybinds.mode.left_click": "Left-click", - "keybinds.mode.right_click": "Right-click", - "keybinds.trigger.left_click": "L-Click", - "keybinds.trigger.right_click": "R-Click", - "keybinds.edit.gate": "Only fire when", - "keybinds.edit.gate_help": "Restrict firing by where the item is: always, only if it is on your hotbar, or only if it is buried in your inventory.", - "keybinds.edit.vanilla_swap": "Vanilla swap", - "keybinds.edit.vanilla_swap_help": "For an item buried in your inventory: visibly bring it to hand, right-click, and swap it back — instead of an instant silent swap.", - "keybinds.edit.delays": "Swap ticks", - "keybinds.edit.delays_help": "Ticks between the vanilla-swap steps: bring-to-hand, right-click, swap-back. 20 ticks = 1 second. Higher looks more human.", - - "keybinds.gate.any": "Always", - "keybinds.gate.in_hotbar": "Item on hotbar", - "keybinds.gate.not_in_hotbar": "Item not on hotbar", - - "keybinds.cond.low_health": "Low health", - "keybinds.cond.on_fire": "On fire", - "keybinds.cond.in_lava": "In lava", - "keybinds.cond.falling": "Falling", - "keybinds.cond.in_water": "In water", - "keybinds.cond.submerged": "Submerged", - "keybinds.cond.hurt": "Hurt", - "keybinds.cond.sneaking": "Sneaking", - "keybinds.cond.sprinting": "Sprinting" + "keybinds.editor.trigger_title": "Edit Trigger", + "keybinds.editor.set_trigger": "+ Set trigger", + "keybinds.editor.trigger_hint": "AND/OR groups nest triggers. ✎ edits, + adds a child, ✕ removes." } From 4dc5d173a121f00e4f71f9a51c5497efbee7e388 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 20:39:34 -0500 Subject: [PATCH 10/19] add drag reorder, multi-select, copy/paste, and undo/redo to the macro editor --- CLAUDE.md | 44 +++ .../keybinds/ui/editor/MacroEditScreen.java | 261 ++++++++++++++++-- .../keybinds/ui/editor/TriggerEditScreen.java | 6 + .../resources/assets/keybinds/lang/en_us.json | 3 + 4 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9e3e2a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +## Principles + +- Write production-ready, scalable, idiomatic code. +- Prefer simple, maintainable solutions. +- Optimize for correctness, readability, and performance. +- Never over-engineer. + +## Code + +- No code comments unless requested. +- Use descriptive names. +- Reuse existing code before adding new abstractions. +- Modify the smallest possible surface area. +- Match the project's existing style. + +## Performance + +- Prefer efficient algorithms over micro-optimizations. +- Avoid unnecessary allocations, work, and dependencies. +- Measure before optimizing. + +## Workflow + +- Understand the codebase before making changes. +- Search before creating. +- Batch related edits. +- Validate with the smallest relevant formatter, linter, typecheck, or tests. +- Never guess APIs or project structure. + +## Tools + +- Minimize tool calls and tokens. +- Read only the files needed. +- Prefer targeted edits over full rewrites. +- Keep responses concise unless more detail is requested. + +## Git + +- Use small, atomic commits. +- Write clear conventional commit messages. +- Never commit secrets, generated files, or broken code. +- Use `gh` for GitHub operations whenever possible. +- Inspect diffs before committing or opening a PR. +- Never force-push or merge without explicit instruction. \ No newline at end of file diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java index 3f0f929..e8e41f6 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java @@ -7,27 +7,46 @@ import com.kingjayan.keybinds.core.macro.MacroRegistry; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; import com.kingjayan.keybinds.persist.MacroStorage; +import com.kingjayan.keybinds.serialize.MacroCodec; +import com.kingjayan.keybinds.ui.framework.ContextMenu; import com.kingjayan.keybinds.ui.framework.FrameworkScreen; import com.kingjayan.keybinds.ui.framework.ScrollPanel; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.CycleButton; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public class MacroEditScreen extends FrameworkScreen { private static final int COLUMN_WIDTH = 340; private static final int ROW_HEIGHT = 24; + private static final int HANDLE_WIDTH = 20; + private static final int UNDO_LIMIT = 50; + + private static final List CLIPBOARD = new ArrayList<>(); private final MacroListScreen parent; private final Macro macro; + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + private final Set selected = new LinkedHashSet<>(); private ScrollPanel actionsPanel; private StringWidget triggerSummary; + private int dragIndex = -1; + private JsonObject dragSnapshot; public MacroEditScreen(MacroListScreen parent, Macro macro) { super(Component.translatable("keybinds.editor.macro_title")); @@ -63,9 +82,11 @@ protected void init() { addRenderableWidget(new StringWidget(left, 76, COLUMN_WIDTH, 12, Component.translatable("keybinds.editor.actions"), font)); - actionsPanel = addRenderableWidget(new ScrollPanel(left, 90, COLUMN_WIDTH, height - 152)); + actionsPanel = addRenderableWidget(new ScrollPanel(left, 90, COLUMN_WIDTH, height - 166)); fillActions(); + addRenderableWidget(new StringWidget(left, height - 58, COLUMN_WIDTH, 12, + Component.translatable("keybinds.editor.shortcuts"), font)); addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.add_action"), b -> addAction()) .bounds(width / 2 - 154, height - 28, 150, 20) .build()); @@ -81,6 +102,7 @@ private Component triggerText() { } private void fillActions() { + selected.removeIf(index -> index >= macro.actions().size()); actionsPanel.clearEntries(); List actions = macro.actions(); if (actions.isEmpty()) { @@ -97,42 +119,242 @@ private void fillActions() { private void addActionRow(int index, int y) { JsonObject json = ActionRegistry.toJson(macro.actions().get(index)); + Button handle = Button.builder(Component.literal("≡"), b -> { + }) + .bounds(0, 0, HANDLE_WIDTH, 20) + .build(); + handle.active = false; + actionsPanel.add(handle, 0, y); + String prefix = selected.contains(index) ? "✔ " : ""; actionsPanel.add(Button.builder( - Component.literal((index + 1) + ". " + EditorSpecs.describeAction(json)), + Component.literal(prefix + (index + 1) + ". " + EditorSpecs.describeAction(json)), b -> editAction(index)) - .bounds(0, 0, COLUMN_WIDTH - 84, 20) - .build(), 0, y); - Button up = Button.builder(Component.literal("↑"), b -> moveAction(index, -1)) - .bounds(0, 0, 20, 20) - .build(); - up.active = index > 0; - actionsPanel.add(up, COLUMN_WIDTH - 80, y); - Button down = Button.builder(Component.literal("↓"), b -> moveAction(index, 1)) - .bounds(0, 0, 20, 20) - .build(); - down.active = index < macro.actions().size() - 1; - actionsPanel.add(down, COLUMN_WIDTH - 56, y); + .bounds(0, 0, COLUMN_WIDTH - HANDLE_WIDTH - 32, 20) + .build(), HANDLE_WIDTH + 4, y); actionsPanel.add(Button.builder(Component.literal("✕"), b -> { + pushUndo(); macro.actions().remove(index); fillActions(); }) .bounds(0, 0, 20, 20) - .build(), COLUMN_WIDTH - 32, y); + .build(), COLUMN_WIDTH - 28, y); + } + + private void toggleSelected(int index) { + if (!selected.remove(index)) { + selected.add(index); + } + fillActions(); + } + + private int rowIndexAt(double mouseY) { + int index = ((int) mouseY - actionsPanel.getY() + (int) actionsPanel.scroll()) / ROW_HEIGHT; + return index >= 0 && index < macro.actions().size() ? index : -1; + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (!hasOverlay() && actionsPanel.isMouseOver(event.x(), event.y())) { + int index = rowIndexAt(event.y()); + if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT && event.hasControlDown()) { + toggleSelected(index); + return true; + } + if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT + && event.x() < actionsPanel.getX() + HANDLE_WIDTH + 2) { + dragIndex = index; + dragSnapshot = MacroCodec.writeMacro(macro); + return true; + } + if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + openRowMenu(index, (int) event.x(), (int) event.y()); + return true; + } + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) { + if (dragIndex >= 0) { + int over = rowIndexAt(event.y()); + if (over >= 0 && over != dragIndex) { + if (dragSnapshot != null) { + recordExternalChange(dragSnapshot); + dragSnapshot = null; + } + Collections.swap(macro.actions(), dragIndex, over); + dragIndex = over; + fillActions(); + } + return true; + } + return super.mouseDragged(event, dx, dy); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + if (dragIndex >= 0) { + dragIndex = -1; + dragSnapshot = null; + return true; + } + return super.mouseReleased(event); + } + + private void openRowMenu(int index, int x, int y) { + List entries = new ArrayList<>(); + entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.edit"), + () -> editAction(index))); + entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.copy"), + () -> copyActions(index))); + if (!CLIPBOARD.isEmpty()) { + entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.paste"), + () -> pasteActions(index + 1))); + } + entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.duplicate"), () -> { + pushUndo(); + Action copy = ActionRegistry.fromJson(ActionRegistry.toJson(macro.actions().get(index))); + if (copy != null) { + macro.actions().add(index + 1, copy); + } + fillActions(); + })); + entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.delete"), () -> { + pushUndo(); + if (selected.contains(index)) { + deleteSelected(); + } else { + macro.actions().remove(index); + } + fillActions(); + })); + openContextMenu(new ContextMenu(font, x, y, entries)); + } + + private void copyActions(int fallbackIndex) { + CLIPBOARD.clear(); + if (selected.isEmpty()) { + CLIPBOARD.add(ActionRegistry.toJson(macro.actions().get(fallbackIndex))); + return; + } + List ordered = new ArrayList<>(selected); + Collections.sort(ordered); + for (int index : ordered) { + CLIPBOARD.add(ActionRegistry.toJson(macro.actions().get(index))); + } } - private void moveAction(int index, int delta) { - int target = index + delta; - if (target < 0 || target >= macro.actions().size()) { + private void pasteActions(int at) { + if (CLIPBOARD.isEmpty()) { return; } - Collections.swap(macro.actions(), index, target); + pushUndo(); + int insert = Math.min(at, macro.actions().size()); + for (JsonObject json : CLIPBOARD) { + Action action = ActionRegistry.fromJson(json.deepCopy()); + if (action != null) { + macro.actions().add(insert++, action); + } + } fillActions(); } + private void deleteSelected() { + List ordered = new ArrayList<>(selected); + ordered.sort(Collections.reverseOrder()); + for (int index : ordered) { + macro.actions().remove(index); + } + selected.clear(); + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (!hasOverlay() && event.hasControlDown() && !(getFocused() instanceof EditBox)) { + if (event.key() == GLFW.GLFW_KEY_Z && event.hasShiftDown()) { + redo(); + return true; + } + switch (event.key()) { + case GLFW.GLFW_KEY_Z -> { + undo(); + return true; + } + case GLFW.GLFW_KEY_Y -> { + redo(); + return true; + } + case GLFW.GLFW_KEY_C -> { + if (!selected.isEmpty()) { + copyActions(-1); + } + return true; + } + case GLFW.GLFW_KEY_V -> { + pasteActions(macro.actions().size()); + return true; + } + } + } + if (!hasOverlay() && event.key() == GLFW.GLFW_KEY_DELETE && !selected.isEmpty() + && !(getFocused() instanceof EditBox)) { + pushUndo(); + deleteSelected(); + fillActions(); + return true; + } + return super.keyPressed(event); + } + + void pushUndo() { + undoStack.push(MacroCodec.writeMacro(macro)); + if (undoStack.size() > UNDO_LIMIT) { + undoStack.removeLast(); + } + redoStack.clear(); + } + + void recordExternalChange(JsonObject before) { + undoStack.push(before); + if (undoStack.size() > UNDO_LIMIT) { + undoStack.removeLast(); + } + redoStack.clear(); + } + + private void undo() { + if (undoStack.isEmpty()) { + return; + } + redoStack.push(MacroCodec.writeMacro(macro)); + restore(undoStack.pop()); + } + + private void redo() { + if (redoStack.isEmpty()) { + return; + } + undoStack.push(MacroCodec.writeMacro(macro)); + restore(redoStack.pop()); + } + + private void restore(JsonObject snapshot) { + Macro restored = MacroCodec.readMacro(snapshot); + macro.setName(restored.name()); + macro.setEnabled(restored.enabled()); + macro.setTrigger(restored.trigger()); + macro.actions().clear(); + macro.actions().addAll(restored.actions()); + selected.clear(); + rebuildWidgets(); + } + private void addAction() { pushModal(new PaletteModal(this, font, "Add action", EditorSpecs.actionTypes(), type -> { Action action = ActionRegistry.fromJson(EditorSpecs.defaultJson(type)); if (action != null) { + pushUndo(); macro.actions().add(action); fillActions(); if (!type.params().isEmpty()) { @@ -151,6 +373,7 @@ private void editAction(int index) { pushModal(new ParamModal(this, font, spec.label(), json.deepCopy(), spec.params(), saved -> { Action parsed = ActionRegistry.fromJson(saved); if (parsed != null) { + pushUndo(); macro.actions().set(index, parsed); } fillActions(); diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java index 6b59942..8a180e6 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java @@ -4,6 +4,7 @@ import com.google.gson.JsonObject; import com.kingjayan.keybinds.core.macro.Macro; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.serialize.MacroCodec; import com.kingjayan.keybinds.ui.framework.FrameworkScreen; import com.kingjayan.keybinds.ui.framework.ScrollPanel; import net.minecraft.client.gui.components.Button; @@ -22,6 +23,7 @@ public class TriggerEditScreen extends FrameworkScreen { private final MacroEditScreen parent; private final Macro macro; + private final JsonObject before; private JsonObject root; private ScrollPanel panel; @@ -32,6 +34,7 @@ public TriggerEditScreen(MacroEditScreen parent, Macro macro) { this.parent = parent; this.macro = macro; this.root = macro.trigger() == null ? null : TriggerRegistry.toJson(macro.trigger()); + this.before = MacroCodec.writeMacro(macro); } @Override @@ -137,6 +140,9 @@ private void editParams(JsonObject node, EditorSpecs.TypeSpec spec) { @Override public void onClose() { macro.setTrigger(root == null ? null : TriggerRegistry.fromJson(root)); + if (!before.equals(MacroCodec.writeMacro(macro))) { + parent.recordExternalChange(before); + } parent.refreshTrigger(); if (minecraft != null) { minecraft.setScreenAndShow(parent); diff --git a/src/main/resources/assets/keybinds/lang/en_us.json b/src/main/resources/assets/keybinds/lang/en_us.json index 210a89b..66e30c6 100644 --- a/src/main/resources/assets/keybinds/lang/en_us.json +++ b/src/main/resources/assets/keybinds/lang/en_us.json @@ -13,6 +13,9 @@ "keybinds.editor.no_actions": "No actions yet — click Add Action.", "keybinds.editor.add_action": "+ Add Action", "keybinds.editor.edit_trigger": "Edit Trigger…", + "keybinds.editor.copy": "Copy", + "keybinds.editor.paste": "Paste below", + "keybinds.editor.shortcuts": "Drag ≡ to reorder · Ctrl+click selects · Ctrl+C/V, Ctrl+Z/Y, Del", "keybinds.editor.trigger_title": "Edit Trigger", "keybinds.editor.set_trigger": "+ Set trigger", From 1f7907a882774457f06e4790f58d2611f3bcdd73 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 20:40:38 -0500 Subject: [PATCH 11/19] rewrite readme for the macro editor --- README.md | 116 ++++++++++++++++-------------------------------------- 1 file changed, 33 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index c0df724..821291b 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,60 @@ # keybinds -A **fully client-side** Fabric mod for Minecraft **26.2**: a tick-based macro engine driving configurable **Use Item** binds and combat helpers. +A **fully client-side** Fabric mod for Minecraft **26.2**: a tick-based macro engine with a full in-game macro editor. -All features run on a shared macro engine with composable triggers (key, mouse, held-item, conditions, AND/OR/NOT) and tick-scheduled actions. Custom macros are stored in `config/macros.json`; press **Right Shift** in-game (or use the Mod Menu config button) to open the macro manager, which also links to the settings screen for the built-in helpers below. - -Each **Use Item** bind right-clicks any item you choose (anything with a right-click action) when one or more configurable triggers activate. Every bind can either **tap** once or **hold** right-click until released. +Every macro pairs a composable **trigger** with an ordered list of tick-scheduled **actions**. Macros are stored in `config/macros.json`; press **Right Shift** in-game (or use the Mod Menu config button) to open the macro editor. ## Triggers -Each bind has one **primary trigger** (chosen in the config UI): - -- **Keyboard key** -- **Left-click** or **right-click**, optionally restricted to specific held item(s). Your normal click still happens—the item use is added on top. - -Binds can also activate when an environment condition first becomes true: - -- Low health -- Hurt -- On fire -- In lava -- Falling -- In water -- Submerged -- Sneaking -- Sprinting +Triggers compose freely with **AND / OR / NOT** groups, nested to any depth: -Additional options: +- **Key press** / **key release** — any GLFW key +- **Mouse press** / **mouse release** — any mouse button +- **Holding item** — the held item matches a list of ids +- **Hotbar slot selected** +- **Riding a mount** +- **Held durability** — the held item's remaining durability drops to a threshold +- **Player condition** — fires the moment it becomes true: low health, hurt, on fire, in lava, falling, in water, submerged, sneaking, sprinting -- **Hotbar filter** — always fire, only while the item is on your hotbar, or only while it's buried in your inventory. -- **Vanilla swap** — for buried items, visibly swap the item into your hand, use it, then swap it back (with configurable delays) instead of using an instant silent swap. +Key and click triggers are edge-based (they fire once per press, not every tick) and are only evaluated while their input is down, so idle macros cost nothing. -### Tap vs Hold +## Actions -**Tap** performs a single right-click. +Actions run in order within a tick until a **Wait** yields, giving tick-perfect sequences: -Examples: ender pearls, wind charges, snowballs, eggs, splash potions, buckets, flint & steel, placing blocks, firing a loaded crossbow. +- **Wait** — pause the macro for N ticks +- **Use specific item** — locate an item (hand → hotbar → buried in inventory) and right-click it; optional hotbar gate, and a **vanilla swap** mode that visibly brings a buried item to hand, uses it, and swaps it back over configurable tick delays +- **Use held item** / **Attack** (optionally swinging) +- **Select hotbar slot** / **Restore starting slot** +- **Select item on hotbar** — by id list or item tag +- **Require item** — abort the macro unless the item is in the inventory +- **Swap to item** / **Swap back** — inventory-wide swap, optionally ranking candidates by an enchantment level (e.g. the best **lunge** spear) +- **Shield/totem offhand swap** -**Hold** keeps right-click held until released. +Classic combos like a mace stun slam (axe attack, wait 1 tick, mace attack) or a lunge spear swap are just short macros built from these actions. -Examples: drawing a bow, loading a crossbow, eating food, spyglass, and other multi-tick items. +Everything uses vanilla item-use and inventory packets, so **no server-side mod is required**. Works in singleplayer and vanilla multiplayer. -## Combat helpers +## Editor -- **Swap Shield / Totem** *(default H)* — toggles your offhand between a shield and a Totem of Undying. -- **Mace Stun Slam** *(default V)* — performs a two-tick axe → mace combo (requires both on your hotbar). -- **Lunge Swap** *(unbound by default)* — swaps to your best spear (the one with the highest **lunge** enchantment level), left-clicks with it, then returns to your original slot. Triggers from a key **and/or** left-click **and/or** right-click, each optional, gated by a held-item filter (a specific item, any item, or an empty hand via `minecraft:air`). Configure the extra triggers, filter, spear ids, and enchantment id from the ⚙ next to its key. +Right Shift opens the macro list: create, enable/disable, duplicate, and delete macros (right-click a row for the context menu). Inside a macro: -Each helper has a **Vanilla** toggle: when on, its swap is spread over a few ticks so it looks like a manual slot change instead of an instant same-tick action. The shared tick spacing lives in `combatSwapToDelay` / `combatUseDelay` / `combatSwapBackDelay`. +- searchable action palette and per-action parameter forms with key/mouse capture +- a visual trigger tree editor for AND/OR/NOT nesting +- drag **≡** to reorder actions, **Ctrl+click** to multi-select +- **Ctrl+C / Ctrl+V** copy/paste (also across macros), **Ctrl+Z / Ctrl+Y** undo/redo, **Del** deletes the selection -Everything uses vanilla item-use and inventory packets, so **no server-side mod is required**. Works in singleplayer and vanilla multiplayer. +Changes are saved to `config/macros.json` when a screen closes. ## Dependencies -Configuration is done in-game — Right Shift opens the macro manager, and Mod Menu's config button opens the same screen. - -- [Mod Menu v20.0.0+](https://modrinth.com/mod/modmenu) +- [Mod Menu v20.0.0+](https://modrinth.com/mod/modmenu) *(optional)* - [Fabric API v0.154.0+26.2](https://modrinth.com/mod/fabric-api) - [Fabric Loader v0.19.3+](https://fabricmc.net) -## How Use Item works - -When triggered, the mod searches for the configured item in this order: - -1. Main hand or offhand → use immediately. -2. Hotbar → briefly switch to the slot, use it, then switch back. -3. Main inventory → swap it into your selected slot, use it, then restore your inventory layout *(Tap mode only).* - -If the item isn't found, nothing happens. - -**Hold** mode only works from your hand or hotbar, since the item must stay selected while being held. It intentionally leaves the item selected afterward. - -Items that target blocks (placing blocks, buckets, flint & steel, etc.) still require looking at a valid block, just like vanilla. - ## Configuration -Helper settings are stored in `config/keybinds.json` and custom macros in `config/macros.json` (created on first launch). Editing them manually is optional—the in-game UI is recommended. - -```json -{ - "itemUses": [ - { "item": "minecraft:wind_charge", "hold": false, "key": 82 }, - { "item": "minecraft:ender_pearl", "hold": false, "key": 71 }, - { "item": "minecraft:white_wool", "hold": false, "key": 88 }, - { "item": "minecraft:bow", "hold": true, "key": 66 } - ], - "shieldTotemKey": 72, - "maceStunSlamKey": 86, - "returnToStartSlotAfterSlam": false, - "shieldTotemVanilla": false, - "maceStunSlamVanilla": false, - "lungeSwapKey": -1, - "lungeSwapLeftClick": false, - "lungeSwapRightClick": false, - "lungeSwapHeldItems": [], - "spearItems": ["minecraft:wooden_spear", "minecraft:stone_spear", "minecraft:iron_spear", "minecraft:golden_spear", "minecraft:diamond_spear", "minecraft:netherite_spear"], - "lungeEnchant": "minecraft:lunge", - "lungeSwapVanilla": false, - "combatSwapToDelay": 2, - "combatUseDelay": 3, - "combatSwapBackDelay": 2 -} -``` - -`key` values are raw [GLFW key codes](https://www.glfw.org/docs/latest/group__keys.html) (`82` = R, `71` = G, `72` = H, `86` = V), though you'll normally configure them through Mod Menu instead. - -Supports **up to 32 Use Item binds**. +Macros live in `config/macros.json` (`formatVersion: 1`); triggers and actions are type-tagged JSON objects. Editing the file manually is optional — the in-game editor is recommended. Key values are raw [GLFW key codes](https://www.glfw.org/docs/latest/group__keys.html), captured for you by the editor. ## Building @@ -124,4 +74,4 @@ To launch a development client: ## License -Released under **CC0**. Feel free to learn from it or incorporate it into your own projects. \ No newline at end of file +Released under **CC0**. Feel free to learn from it or incorporate it into your own projects. From b64d284fbf06b25deaea1c456b5d52e0c0b8c907 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 21:00:29 -0500 Subject: [PATCH 12/19] fix trigger index dropping macros without key or button inputs and remove per-tick allocations --- .../keybinds/core/trigger/TriggerContext.java | 9 +++++---- .../keybinds/core/trigger/TriggerIndex.java | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java index cf2e204..ea319c2 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java @@ -13,8 +13,8 @@ public final class TriggerContext { - private final Map previous = new IdentityHashMap<>(); - private final Map current = new IdentityHashMap<>(); + private Map previous = new IdentityHashMap<>(); + private Map current = new IdentityHashMap<>(); private boolean resync = true; private Minecraft client; @@ -69,8 +69,9 @@ public boolean edge(Trigger node, boolean active) { } public void commit() { - previous.clear(); - previous.putAll(current); + Map swap = previous; + previous = current; + current = swap; current.clear(); resync = false; } diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java index 2eb8cb4..dc38703 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java @@ -18,6 +18,9 @@ public final class TriggerIndex { private final Map> byMouseButton = new HashMap<>(); private final List polled = new ArrayList<>(); private final Map order = new IdentityHashMap<>(); + private final List candidateBuffer = new ArrayList<>(); + private final Set seenBuffer = Collections.newSetFromMap(new IdentityHashMap<>()); + private final Comparator byOrder = Comparator.comparingInt(order::get); public void rebuild(List macros) { byKey.clear(); @@ -28,8 +31,11 @@ public void rebuild(List macros) { } public List candidates(TriggerContext ctx) { - List result = new ArrayList<>(polled); - Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + List result = candidateBuffer; + Set seen = seenBuffer; + result.clear(); + seen.clear(); + result.addAll(polled); seen.addAll(polled); for (Map.Entry> entry : byKey.entrySet()) { if (ctx.keyDown(entry.getKey())) { @@ -41,7 +47,7 @@ public List candidates(TriggerContext ctx) { addUnique(result, seen, entry.getValue()); } } - result.sort(Comparator.comparingInt(order::get)); + result.sort(byOrder); return result; } @@ -60,10 +66,11 @@ private void index(List macros) { } Classification classification = new Classification(); classify(macro.trigger(), classification); - if (classification.polled) { + if (classification.polled + || (classification.keys.isEmpty() && classification.buttons.isEmpty())) { order.put(macro, order.size()); polled.add(macro); - } else if (!classification.keys.isEmpty() || !classification.buttons.isEmpty()) { + } else { order.put(macro, order.size()); for (int key : classification.keys) { byKey.computeIfAbsent(key, unused -> new ArrayList<>()).add(macro); From aea37ad71859c797a698e593ce49a076e823edb6 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 21:00:29 -0500 Subject: [PATCH 13/19] add serialization stress and trigger index tests --- build.gradle | 7 + .../keybinds/SerializationStressTest.java | 205 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/test/java/com/kingjayan/keybinds/SerializationStressTest.java diff --git a/build.gradle b/build.gradle index 4a122d9..92a1792 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,13 @@ dependencies { // entrypoint only loads when it is present), but needed on the compile classpath. // 26.2 ships official mappings, so no remap configuration is needed. implementation "maven.modrinth:modmenu:${project.modmenu_version}" + + testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}" + testImplementation "org.junit.jupiter:junit-jupiter:5.11.4" +} + +test { + useJUnitPlatform() } processResources { diff --git a/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java b/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java new file mode 100644 index 0000000..fe58adb --- /dev/null +++ b/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java @@ -0,0 +1,205 @@ +package com.kingjayan.keybinds; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.kingjayan.keybinds.core.action.ActionTypes; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.trigger.TriggerContext; +import com.kingjayan.keybinds.core.trigger.TriggerIndex; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.core.trigger.TriggerTypes; +import com.kingjayan.keybinds.serialize.MacroCodec; +import com.kingjayan.keybinds.ui.editor.EditorSpecs; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SerializationStressTest { + + @BeforeAll + static void bootstrap() { + ActionTypes.bootstrap(); + TriggerTypes.bootstrap(); + } + + @Test + void fullCatalogRoundTripIsStable() { + JsonObject macroJson = new JsonObject(); + macroJson.addProperty("id", UUID.randomUUID().toString()); + macroJson.addProperty("name", "catalog"); + macroJson.addProperty("enabled", true); + JsonObject trigger = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("any_of")); + JsonArray children = trigger.getAsJsonArray("children"); + for (EditorSpecs.TypeSpec spec : EditorSpecs.triggerTypes()) { + if (spec.nesting() == EditorSpecs.Nesting.NONE) { + children.add(EditorSpecs.defaultJson(spec)); + } + } + JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not")); + not.add("child", EditorSpecs.defaultJson(EditorSpecs.triggerSpec("riding"))); + children.add(not); + macroJson.add("trigger", trigger); + JsonArray actions = new JsonArray(); + for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) { + actions.add(EditorSpecs.defaultJson(spec)); + } + macroJson.add("actions", actions); + + Macro macro = MacroCodec.readMacro(macroJson); + assertNotNull(macro.trigger()); + assertEquals(EditorSpecs.actionTypes().size(), macro.actions().size()); + JsonObject written = MacroCodec.writeMacro(macro); + assertEquals(written, MacroCodec.writeMacro(MacroCodec.readMacro(written))); + } + + @Test + void fiveHundredRandomMacrosRoundTrip() { + Random random = new Random(20260712); + List macros = new ArrayList<>(); + for (int i = 0; i < 500; i++) { + JsonObject json = new JsonObject(); + json.addProperty("id", new UUID(random.nextLong(), random.nextLong()).toString()); + json.addProperty("name", "macro " + i); + json.addProperty("enabled", random.nextBoolean()); + json.add("trigger", randomTriggerTree(random, 0)); + JsonArray actions = new JsonArray(); + int count = 1 + random.nextInt(10); + List actionSpecs = EditorSpecs.actionTypes(); + for (int a = 0; a < count; a++) { + actions.add(randomize(EditorSpecs.defaultJson( + actionSpecs.get(random.nextInt(actionSpecs.size()))), random)); + } + json.add("actions", actions); + macros.add(MacroCodec.readMacro(json)); + } + JsonObject first = MacroCodec.write(macros); + List reloaded = MacroCodec.read(first); + assertEquals(500, reloaded.size()); + assertEquals(first, MacroCodec.write(reloaded)); + + TriggerIndex index = new TriggerIndex(); + long start = System.nanoTime(); + index.rebuild(reloaded); + long rebuildMicros = (System.nanoTime() - start) / 1000; + assertTrue(rebuildMicros < 1_000_000, "index rebuild took " + rebuildMicros + "µs"); + } + + @Test + void malformedInputIsTolerated() { + JsonObject newer = new JsonObject(); + newer.addProperty("formatVersion", MacroCodec.FORMAT_VERSION + 1); + assertTrue(MacroCodec.read(newer).isEmpty()); + + JsonObject root = new JsonObject(); + JsonArray macros = new JsonArray(); + macros.add(new JsonPrimitive("garbage")); + JsonObject bad = new JsonObject(); + bad.addProperty("id", "not-a-uuid"); + JsonObject badTrigger = new JsonObject(); + badTrigger.addProperty("type", "does_not_exist"); + bad.add("trigger", badTrigger); + JsonArray badActions = new JsonArray(); + JsonObject badAction = new JsonObject(); + badAction.addProperty("type", "also_missing"); + badActions.add(badAction); + badActions.add(new JsonPrimitive(7)); + bad.add("actions", badActions); + macros.add(bad); + root.add("macros", macros); + + List read = MacroCodec.read(root); + assertEquals(1, read.size()); + Macro macro = read.getFirst(); + assertNotNull(macro.id()); + assertEquals("Unnamed", macro.name()); + assertTrue(macro.actions().isEmpty()); + assertEquals(null, macro.trigger()); + } + + @Test + void indexKeepsNonKeyedTriggersAsCandidates() { + JsonObject keyed = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key")); + keyed.addProperty("key", 65); + Macro keyMacro = macroWithTrigger(keyed); + + JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not")); + JsonObject notChild = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key")); + notChild.addProperty("key", 66); + not.add("child", notChild); + Macro notMacro = macroWithTrigger(not); + + Macro conditionMacro = macroWithTrigger( + EditorSpecs.defaultJson(EditorSpecs.triggerSpec("condition"))); + + TriggerIndex index = new TriggerIndex(); + index.rebuild(List.of(keyMacro, notMacro, conditionMacro)); + List candidates = index.candidates(new TriggerContext()); + assertTrue(candidates.contains(notMacro)); + assertTrue(candidates.contains(conditionMacro)); + assertFalse(candidates.contains(keyMacro)); + } + + private static Macro macroWithTrigger(JsonObject trigger) { + JsonObject json = new JsonObject(); + json.addProperty("id", UUID.randomUUID().toString()); + json.addProperty("name", "test"); + json.add("trigger", trigger); + json.add("actions", new JsonArray()); + return MacroCodec.readMacro(json); + } + + private static JsonObject randomTriggerTree(Random random, int depth) { + List specs = EditorSpecs.triggerTypes(); + EditorSpecs.TypeSpec spec = specs.get(random.nextInt(specs.size())); + if (depth >= 3 && spec.nesting() != EditorSpecs.Nesting.NONE) { + spec = EditorSpecs.triggerSpec("key"); + } + JsonObject json = randomize(EditorSpecs.defaultJson(spec), random); + if (spec.nesting() == EditorSpecs.Nesting.MANY) { + JsonArray children = json.getAsJsonArray("children"); + int count = 1 + random.nextInt(3); + for (int i = 0; i < count; i++) { + children.add(randomTriggerTree(random, depth + 1)); + } + } else if (spec.nesting() == EditorSpecs.Nesting.ONE) { + json.add("child", randomTriggerTree(random, depth + 1)); + } + return json; + } + + private static JsonObject randomize(JsonObject json, Random random) { + String type = json.get("type").getAsString(); + EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(type) != null + ? EditorSpecs.actionSpec(type) : EditorSpecs.triggerSpec(type); + for (EditorSpecs.ParamSpec param : spec.params()) { + switch (param.kind()) { + case INT -> json.addProperty(param.key(), random.nextInt(100)); + case KEY, MOUSE -> json.addProperty(param.key(), random.nextInt(120)); + case BOOL -> json.addProperty(param.key(), random.nextBoolean()); + case STRING -> json.addProperty(param.key(), + random.nextBoolean() ? "" : "minecraft:stick"); + case ENUM -> json.addProperty(param.key(), + param.options().get(random.nextInt(param.options().size()))); + case STRING_LIST -> { + JsonArray items = new JsonArray(); + if (random.nextBoolean()) { + items.add("minecraft:mace"); + items.add("minecraft:wind_charge"); + } + json.add(param.key(), items); + } + } + } + return json; + } +} From 7c73a6619dfc96d8ed8946dd2301b1b6c31974c7 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Sun, 12 Jul 2026 21:01:05 -0500 Subject: [PATCH 14/19] ignore fabric test logs directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index de8e0a5..1582394 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ bin/ # fabric run/ +logs/ # java From 76c90342b44b99ca63d5a34acc8e987fbb569bed Mon Sep 17 00:00:00 2001 From: KingJayan Date: Mon, 13 Jul 2026 17:41:08 -0500 Subject: [PATCH 15/19] add macro script dsl with compiler, gated script runtime, and tests --- .../kingjayan/keybinds/KeybindsClient.java | 10 +- .../kingjayan/keybinds/core/macro/Macro.java | 9 + .../com/kingjayan/keybinds/dsl/KeyNames.java | 105 +++++++ .../com/kingjayan/keybinds/dsl/ScriptAst.java | 55 ++++ .../keybinds/dsl/ScriptCompiler.java | 292 ++++++++++++++++++ .../kingjayan/keybinds/dsl/ScriptDocs.java | 100 ++++++ .../keybinds/dsl/ScriptException.java | 15 + .../keybinds/dsl/ScriptExecution.java | 91 ++++++ .../kingjayan/keybinds/dsl/ScriptLexer.java | 103 ++++++ .../kingjayan/keybinds/dsl/ScriptParser.java | 230 ++++++++++++++ .../kingjayan/keybinds/dsl/ScriptPrinter.java | 157 ++++++++++ .../kingjayan/keybinds/dsl/ScriptProgram.java | 151 +++++++++ .../kingjayan/keybinds/dsl/ScriptRuntime.java | 89 ++++++ .../keybinds/serialize/MacroCodec.java | 10 +- .../com/kingjayan/keybinds/ScriptDslTest.java | 156 ++++++++++ 15 files changed, 1571 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java create mode 100644 src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java create mode 100644 src/test/java/com/kingjayan/keybinds/ScriptDslTest.java diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java index dfef8d3..953e3f2 100644 --- a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java +++ b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java @@ -9,6 +9,7 @@ import com.kingjayan.keybinds.core.trigger.TriggerContext; import com.kingjayan.keybinds.core.trigger.TriggerIndex; import com.kingjayan.keybinds.core.trigger.TriggerTypes; +import com.kingjayan.keybinds.dsl.ScriptRuntime; import com.kingjayan.keybinds.persist.MacroStorage; import com.kingjayan.keybinds.ui.editor.MacroListScreen; import com.mojang.blaze3d.platform.InputConstants; @@ -26,6 +27,7 @@ public class KeybindsClient implements ClientModInitializer { private final TriggerContext triggers = new TriggerContext(); private final EdgeDetector screenKeyEdges = new EdgeDetector(); private final TriggerIndex triggerIndex = new TriggerIndex(); + private final ScriptRuntime scripts = new ScriptRuntime(); private int indexedRevision = -1; @@ -38,6 +40,7 @@ public void onInitializeClient() { ClientTickEvents.END_CLIENT_TICK.register(client -> { scheduler.tick(); engine.tick(); + scripts.tick(); if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) { triggers.reset(); @@ -47,6 +50,7 @@ public void onInitializeClient() { int revision = MacroRegistry.get().revision(); if (revision != indexedRevision) { + scripts.recompile(MacroRegistry.get().all()); triggerIndex.rebuild(MacroRegistry.get().all()); indexedRevision = revision; } @@ -73,7 +77,11 @@ private void fireMacros(Minecraft client, List macros) { continue; } if (macro.trigger().evaluate(triggers).fired()) { - engine.start(macro, client, scheduler); + if (macro.script() != null) { + scripts.start(macro, client, scheduler); + } else { + engine.start(macro, client, scheduler); + } } } } diff --git a/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java index 4d0d46e..075024c 100644 --- a/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java +++ b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java @@ -14,6 +14,7 @@ public final class Macro { private boolean enabled; private Trigger trigger; private final List actions; + private String script; public Macro(UUID id, String name, boolean enabled, Trigger trigger, List actions) { this.id = id; @@ -58,4 +59,12 @@ public void setTrigger(Trigger trigger) { public List actions() { return actions; } + + public String script() { + return script; + } + + public void setScript(String script) { + this.script = script; + } } diff --git a/src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java b/src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java new file mode 100644 index 0000000..1ffe141 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java @@ -0,0 +1,105 @@ +package com.kingjayan.keybinds.dsl; + +import org.lwjgl.glfw.GLFW; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class KeyNames { + + private static final Map KEYS = new LinkedHashMap<>(); + private static final Map KEY_NAMES = new LinkedHashMap<>(); + private static final Map BUTTONS = new LinkedHashMap<>(); + private static final Map BUTTON_NAMES = new LinkedHashMap<>(); + + static { + for (char c = 'a'; c <= 'z'; c++) { + key(String.valueOf(c), GLFW.GLFW_KEY_A + (c - 'a')); + } + for (char c = '0'; c <= '9'; c++) { + key(String.valueOf(c), GLFW.GLFW_KEY_0 + (c - '0')); + } + for (int i = 1; i <= 25; i++) { + key("f" + i, GLFW.GLFW_KEY_F1 + (i - 1)); + } + key("space", GLFW.GLFW_KEY_SPACE); + key("escape", GLFW.GLFW_KEY_ESCAPE); + key("enter", GLFW.GLFW_KEY_ENTER); + key("tab", GLFW.GLFW_KEY_TAB); + key("backspace", GLFW.GLFW_KEY_BACKSPACE); + key("insert", GLFW.GLFW_KEY_INSERT); + key("delete", GLFW.GLFW_KEY_DELETE); + key("right", GLFW.GLFW_KEY_RIGHT); + key("left", GLFW.GLFW_KEY_LEFT); + key("down", GLFW.GLFW_KEY_DOWN); + key("up", GLFW.GLFW_KEY_UP); + key("page_up", GLFW.GLFW_KEY_PAGE_UP); + key("page_down", GLFW.GLFW_KEY_PAGE_DOWN); + key("home", GLFW.GLFW_KEY_HOME); + key("end", GLFW.GLFW_KEY_END); + key("caps_lock", GLFW.GLFW_KEY_CAPS_LOCK); + key("left_shift", GLFW.GLFW_KEY_LEFT_SHIFT); + key("left_control", GLFW.GLFW_KEY_LEFT_CONTROL); + key("left_alt", GLFW.GLFW_KEY_LEFT_ALT); + key("right_shift", GLFW.GLFW_KEY_RIGHT_SHIFT); + key("right_control", GLFW.GLFW_KEY_RIGHT_CONTROL); + key("right_alt", GLFW.GLFW_KEY_RIGHT_ALT); + key("apostrophe", GLFW.GLFW_KEY_APOSTROPHE); + key("comma", GLFW.GLFW_KEY_COMMA); + key("minus", GLFW.GLFW_KEY_MINUS); + key("period", GLFW.GLFW_KEY_PERIOD); + key("slash", GLFW.GLFW_KEY_SLASH); + key("semicolon", GLFW.GLFW_KEY_SEMICOLON); + key("equal", GLFW.GLFW_KEY_EQUAL); + key("left_bracket", GLFW.GLFW_KEY_LEFT_BRACKET); + key("backslash", GLFW.GLFW_KEY_BACKSLASH); + key("right_bracket", GLFW.GLFW_KEY_RIGHT_BRACKET); + key("grave", GLFW.GLFW_KEY_GRAVE_ACCENT); + button("left", GLFW.GLFW_MOUSE_BUTTON_LEFT); + button("right", GLFW.GLFW_MOUSE_BUTTON_RIGHT); + button("middle", GLFW.GLFW_MOUSE_BUTTON_MIDDLE); + button("button4", GLFW.GLFW_MOUSE_BUTTON_4); + button("button5", GLFW.GLFW_MOUSE_BUTTON_5); + button("button6", GLFW.GLFW_MOUSE_BUTTON_6); + button("button7", GLFW.GLFW_MOUSE_BUTTON_7); + button("button8", GLFW.GLFW_MOUSE_BUTTON_8); + } + + private KeyNames() { + } + + private static void key(String name, int code) { + KEYS.put(name, code); + KEY_NAMES.putIfAbsent(code, name); + } + + private static void button(String name, int code) { + BUTTONS.put(name, code); + BUTTON_NAMES.putIfAbsent(code, name); + } + + public static Integer keyCode(String name) { + return KEYS.get(name.toLowerCase()); + } + + public static Integer buttonCode(String name) { + return BUTTONS.get(name.toLowerCase()); + } + + public static String keyName(int code) { + return KEY_NAMES.get(code); + } + + public static String buttonName(int code) { + return BUTTON_NAMES.get(code); + } + + public static List keyNames() { + return List.copyOf(KEYS.keySet()); + } + + public static List buttonNames() { + return List.copyOf(BUTTONS.keySet()); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java new file mode 100644 index 0000000..ea68166 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java @@ -0,0 +1,55 @@ +package com.kingjayan.keybinds.dsl; + +import java.util.List; + +public final class ScriptAst { + + private ScriptAst() { + } + + public sealed interface Expr permits AndExpr, OrExpr, NotExpr, CallExpr { + int line(); + } + + public record AndExpr(List children, int line) implements Expr { + } + + public record OrExpr(List children, int line) implements Expr { + } + + public record NotExpr(Expr child, int line) implements Expr { + } + + public record CallExpr(String name, List args, int line) implements Expr { + } + + public record Arg(String name, Value value) { + } + + public sealed interface Value permits IntValue, StrValue, BoolValue, ListValue { + } + + public record IntValue(int value) implements Value { + } + + public record StrValue(String value, boolean bare) implements Value { + } + + public record BoolValue(boolean value) implements Value { + } + + public record ListValue(List values) implements Value { + } + + public sealed interface Stmt permits ActionStmt, IfStmt { + } + + public record ActionStmt(CallExpr call) implements Stmt { + } + + public record IfStmt(Expr condition, List thenBody, List elseBody, int line) implements Stmt { + } + + public record Script(Expr when, List body) { + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java new file mode 100644 index 0000000..9477d12 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java @@ -0,0 +1,292 @@ +package com.kingjayan.keybinds.dsl; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.core.trigger.Trigger; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.dsl.ScriptAst.ActionStmt; +import com.kingjayan.keybinds.dsl.ScriptAst.AndExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Arg; +import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue; +import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Expr; +import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt; +import com.kingjayan.keybinds.dsl.ScriptAst.IntValue; +import com.kingjayan.keybinds.dsl.ScriptAst.ListValue; +import com.kingjayan.keybinds.dsl.ScriptAst.NotExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.OrExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Script; +import com.kingjayan.keybinds.dsl.ScriptAst.Stmt; +import com.kingjayan.keybinds.dsl.ScriptAst.StrValue; +import com.kingjayan.keybinds.dsl.ScriptAst.Value; +import com.kingjayan.keybinds.dsl.ScriptProgram.Condition; +import com.kingjayan.keybinds.dsl.ScriptProgram.Instruction; +import com.kingjayan.keybinds.ui.editor.EditorSpecs; + +import java.util.ArrayList; +import java.util.List; + +public final class ScriptCompiler { + + private ScriptCompiler() { + } + + public static ScriptProgram compile(String source) { + Script script = ScriptParser.parse(source); + JsonObject triggerJson = triggerJson(script.when()); + Trigger trigger = TriggerRegistry.fromJson(triggerJson); + if (trigger == null) { + throw new ScriptException(script.when().line(), "Trigger failed to build"); + } + List instructions = new ArrayList<>(); + emit(script.body(), instructions); + return new ScriptProgram(trigger, instructions); + } + + private static void emit(List statements, List out) { + for (Stmt stmt : statements) { + switch (stmt) { + case ActionStmt action -> out.add(Instruction.act(buildAction(action.call()))); + case IfStmt ifStmt -> { + Instruction branch = Instruction.jumpIfFalse(condition(ifStmt.condition())); + out.add(branch); + emit(ifStmt.thenBody(), out); + if (ifStmt.elseBody().isEmpty()) { + branch.target = out.size(); + } else { + Instruction skipElse = Instruction.jump(); + out.add(skipElse); + branch.target = out.size(); + emit(ifStmt.elseBody(), out); + skipElse.target = out.size(); + } + } + } + } + } + + private static Action buildAction(CallExpr call) { + EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(call.name()); + if (spec == null) { + throw new ScriptException(call.line(), "Unknown action '" + call.name() + "'"); + } + Action action = ActionRegistry.fromJson(applyArgs(spec, call)); + if (action == null) { + throw new ScriptException(call.line(), "Action '" + call.name() + "' failed to build"); + } + return action; + } + + private static JsonObject triggerJson(Expr expr) { + switch (expr) { + case AndExpr and -> { + return group("all_of", and.children()); + } + case OrExpr or -> { + return group("any_of", or.children()); + } + case NotExpr not -> { + JsonObject json = new JsonObject(); + json.addProperty("type", "not"); + json.add("child", triggerJson(not.child())); + return json; + } + case CallExpr call -> { + return triggerCallJson(call); + } + } + } + + private static JsonObject group(String type, List children) { + JsonObject json = new JsonObject(); + json.addProperty("type", type); + JsonArray array = new JsonArray(); + for (Expr child : children) { + array.add(triggerJson(child)); + } + json.add("children", array); + return json; + } + + private static JsonObject triggerCallJson(CallExpr call) { + String name = resolveTriggerName(call.name()); + if (name == null) { + throw new ScriptException(call.line(), "Unknown trigger '" + call.name() + "'"); + } + if (name.equals("condition") && call.args().isEmpty() && !call.name().equals("condition")) { + JsonObject json = new JsonObject(); + json.addProperty("type", "condition"); + json.addProperty("condition", call.name()); + return json; + } + EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(name); + return applyArgs(spec, call); + } + + private static String resolveTriggerName(String name) { + String resolved = switch (name) { + case "mouse" -> "mouse_button"; + case "all_of", "any_of", "not" -> null; + default -> name; + }; + if (resolved != null && EditorSpecs.triggerSpec(resolved) != null) { + return resolved; + } + if (PlayerConditions.ids().contains(name)) { + return "condition"; + } + return null; + } + + private static Condition condition(Expr expr) { + switch (expr) { + case AndExpr and -> { + return new ScriptProgram.AllCondition(conditions(and.children())); + } + case OrExpr or -> { + return new ScriptProgram.AnyCondition(conditions(or.children())); + } + case NotExpr not -> { + return new ScriptProgram.NotCondition(condition(not.child())); + } + case CallExpr call -> { + return leafCondition(call); + } + } + } + + private static List conditions(List children) { + List out = new ArrayList<>(children.size()); + for (Expr child : children) { + out.add(condition(child)); + } + return out; + } + + private static Condition leafCondition(CallExpr call) { + String name = resolveTriggerName(call.name()); + if (name == null) { + throw new ScriptException(call.line(), "Unknown condition '" + call.name() + "'"); + } + if (name.equals("condition") && !call.name().equals("condition")) { + return new ScriptProgram.PlayerCondition(call.name()); + } + EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(name); + JsonObject json = applyArgs(spec, call); + return switch (name) { + case "key" -> new ScriptProgram.KeyDownCondition(json.get("key").getAsInt()); + case "mouse_button" -> new ScriptProgram.MouseDownCondition(json.get("button").getAsInt()); + case "condition" -> new ScriptProgram.PlayerCondition(json.get("condition").getAsString()); + case "holding" -> new ScriptProgram.HoldingCondition( + EditorSpecs.readStringList(json, "items")); + case "riding" -> new ScriptProgram.RidingCondition(); + case "slot_selected" -> new ScriptProgram.SlotCondition(json.get("slot").getAsInt()); + case "held_durability" -> new ScriptProgram.DurabilityCondition(json.get("durability").getAsInt()); + default -> throw new ScriptException(call.line(), + "'" + call.name() + "' cannot be used inside if — only state checks are allowed"); + }; + } + + private static JsonObject applyArgs(EditorSpecs.TypeSpec spec, CallExpr call) { + JsonObject json = EditorSpecs.defaultJson(spec); + List params = spec.params(); + int positional = 0; + for (Arg arg : call.args()) { + EditorSpecs.ParamSpec param; + if (arg.name() != null) { + param = params.stream().filter(p -> p.key().equals(arg.name())).findFirst().orElse(null); + if (param == null) { + throw new ScriptException(call.line(), "'" + spec.id() + "' has no parameter '" + + arg.name() + "'" + expected(params)); + } + } else { + if (positional < params.size()) { + param = params.get(positional++); + } else if (!params.isEmpty() + && params.getLast().kind() == EditorSpecs.Kind.STRING_LIST) { + param = params.getLast(); + } else { + throw new ScriptException(call.line(), "Too many arguments for '" + spec.id() + "'" + + expected(params)); + } + } + applyValue(json, param, arg.value(), call.line()); + } + return json; + } + + private static String expected(List params) { + if (params.isEmpty()) { + return " — it takes no arguments"; + } + StringBuilder sb = new StringBuilder(" — parameters: "); + for (int i = 0; i < params.size(); i++) { + sb.append(i > 0 ? ", " : "").append(params.get(i).key()); + } + return sb.toString(); + } + + private static void applyValue(JsonObject json, EditorSpecs.ParamSpec param, Value value, int line) { + switch (param.kind()) { + case INT -> json.addProperty(param.key(), intValue(param, value, line)); + case KEY -> json.addProperty(param.key(), keyValue(param, value, line, true)); + case MOUSE -> json.addProperty(param.key(), keyValue(param, value, line, false)); + case BOOL -> { + if (!(value instanceof BoolValue bool)) { + throw new ScriptException(line, "'" + param.key() + "' expects true or false"); + } + json.addProperty(param.key(), bool.value()); + } + case STRING -> json.addProperty(param.key(), stringValue(param, value, line)); + case ENUM -> { + String text = stringValue(param, value, line); + if (!param.options().contains(text)) { + throw new ScriptException(line, "'" + param.key() + "' must be one of " + + String.join(", ", param.options())); + } + json.addProperty(param.key(), text); + } + case STRING_LIST -> { + JsonArray array = json.getAsJsonArray(param.key()); + switch (value) { + case ListValue list -> list.values().forEach(array::add); + case StrValue str -> array.add(str.value()); + default -> throw new ScriptException(line, + "'" + param.key() + "' expects a string or a [list]"); + } + } + } + } + + private static int intValue(EditorSpecs.ParamSpec param, Value value, int line) { + if (value instanceof IntValue intValue) { + return intValue.value(); + } + throw new ScriptException(line, "'" + param.key() + "' expects a number"); + } + + private static int keyValue(EditorSpecs.ParamSpec param, Value value, int line, boolean keyboard) { + if (value instanceof IntValue intValue) { + return intValue.value(); + } + if (value instanceof StrValue str) { + Integer code = keyboard ? KeyNames.keyCode(str.value()) : KeyNames.buttonCode(str.value()); + if (code != null) { + return code; + } + throw new ScriptException(line, "Unknown " + (keyboard ? "key" : "mouse button") + + " name '" + str.value() + "'"); + } + throw new ScriptException(line, "'" + param.key() + "' expects a key name or code"); + } + + private static String stringValue(EditorSpecs.ParamSpec param, Value value, int line) { + if (value instanceof StrValue str) { + return str.value(); + } + throw new ScriptException(line, "'" + param.key() + "' expects a string"); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java new file mode 100644 index 0000000..6f8c8ad --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java @@ -0,0 +1,100 @@ +package com.kingjayan.keybinds.dsl; + +import com.kingjayan.keybinds.core.condition.PlayerConditions; + +import java.util.ArrayList; +import java.util.List; + +public final class ScriptDocs { + + public enum Kind { + HEADER, TEXT, TRIGGER, ACTION, CONDITION + } + + public record Entry(Kind kind, String name, String signature, String description) { + + static Entry header(String name) { + return new Entry(Kind.HEADER, name, "", ""); + } + + static Entry text(String description) { + return new Entry(Kind.TEXT, "", "", description); + } + } + + private static final List ENTRIES = new ArrayList<>(); + private static final List COMPLETIONS = new ArrayList<>(); + + static { + ENTRIES.add(Entry.header("Syntax")); + ENTRIES.add(Entry.text("A script starts with one 'when ' line, followed by actions, one per line. '#' starts a comment.")); + ENTRIES.add(Entry.text("Triggers combine with and / or / not and parentheses: when key(r) and not riding")); + ENTRIES.add(Entry.text("Arguments are positional or named: use_item(item=\"minecraft:ender_pearl\", useDelay=3). Lists use [\"a\", \"b\"].")); + ENTRIES.add(Entry.text("if { ... } else { ... } branches while the macro runs. Checks are live state: holding, riding, key (held down), mouse, slot_selected, held_durability, and any player condition.")); + ENTRIES.add(Entry.text("Key names: a-z, 0-9, f1-f25, space, left_shift, ... or a raw GLFW code. Mouse: left, right, middle, button4+.")); + + ENTRIES.add(Entry.header("Triggers")); + trigger("key", "key(r)", "Fires when the key is pressed. Inside if: true while held."); + trigger("key_release", "key_release(r)", "Fires when the key is released."); + trigger("mouse", "mouse(left)", "Fires when the mouse button is pressed. Inside if: true while held."); + trigger("mouse_release", "mouse_release(left)", "Fires when the mouse button is released."); + trigger("holding", "holding(\"minecraft:mace\")", "Fires when you start holding one of the items; empty matches anything."); + trigger("slot_selected", "slot_selected(0)", "Fires when the hotbar slot (0-8) is selected."); + trigger("riding", "riding", "Fires when you mount a vehicle or mob."); + trigger("held_durability", "held_durability(10)", "Fires when the held item's remaining durability drops to the value or less."); + trigger("condition", "condition(\"low_health\")", "Fires when the player condition becomes true; bare names also work: sneaking."); + + ENTRIES.add(Entry.header("Actions")); + action("wait", "wait(2)", "Pauses the macro for that many game ticks."); + action("select_slot", "select_slot(0)", "Selects a hotbar slot (0-8)."); + action("restore_slot", "restore_slot", "Reselects the slot that was active when the macro started."); + action("select_item", "select_item(items=[\"minecraft:mace\"], tag=\"\")", "Selects a matching item on the hotbar; aborts if none found."); + action("require_item", "require_item(items=[\"minecraft:totem_of_undying\"])", "Aborts the macro unless a matching item is in the inventory."); + action("swap_to_item", "swap_to_item(items=[\"minecraft:mace\"], rankEnchant=\"\")", "Swaps a matching inventory item into the hand, remembering the swap."); + action("swap_back", "swap_back", "Undoes the most recent swap_to_item."); + action("attack", "attack(swing=true)", "Attacks with the held item."); + action("use", "use", "Uses the held item once."); + action("use_item", "use_item(item=\"minecraft:ender_pearl\", hotbarGate=any, vanillaSwap=false, swapToDelay=2, useDelay=3, swapBackDelay=2)", "Swaps to an item, uses it, and swaps back with configurable tick delays."); + action("shield_totem_swap", "shield_totem_swap", "Swaps shield and totem in the offhand."); + + ENTRIES.add(Entry.header("Player conditions")); + for (String id : PlayerConditions.ids()) { + Entry entry = new Entry(Kind.CONDITION, id, id, "Player condition, usable as a trigger or if check."); + ENTRIES.add(entry); + COMPLETIONS.add(entry); + } + } + + private ScriptDocs() { + } + + private static void trigger(String name, String signature, String description) { + Entry entry = new Entry(Kind.TRIGGER, name, signature, description); + ENTRIES.add(entry); + COMPLETIONS.add(entry); + } + + private static void action(String name, String signature, String description) { + Entry entry = new Entry(Kind.ACTION, name, signature, description); + ENTRIES.add(entry); + COMPLETIONS.add(entry); + } + + public static List entries() { + return List.copyOf(ENTRIES); + } + + public static List completions(boolean triggerContext, String prefix) { + List matches = new ArrayList<>(); + for (Entry entry : COMPLETIONS) { + boolean isTrigger = entry.kind() == Kind.TRIGGER || entry.kind() == Kind.CONDITION; + if (isTrigger != triggerContext) { + continue; + } + if (entry.name().startsWith(prefix) && !entry.name().equals(prefix)) { + matches.add(entry); + } + } + return matches; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java new file mode 100644 index 0000000..fca5b34 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java @@ -0,0 +1,15 @@ +package com.kingjayan.keybinds.dsl; + +public final class ScriptException extends RuntimeException { + + private final int line; + + public ScriptException(int line, String message) { + super(message); + this.line = line; + } + + public int line() { + return line; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java new file mode 100644 index 0000000..9cc0c86 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java @@ -0,0 +1,91 @@ +package com.kingjayan.keybinds.dsl; + +import com.kingjayan.keybinds.core.action.ActionContext; +import com.kingjayan.keybinds.core.action.ActionResult; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.dsl.ScriptProgram.Instruction; +import net.minecraft.client.Minecraft; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ScriptExecution { + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + private static final int STEP_LIMIT = 10_000; + + private final Macro macro; + private final ScriptProgram program; + private final ActionContext context; + + private int pc; + private int delay; + private boolean aborted; + + public ScriptExecution(Macro macro, ScriptProgram program, ActionContext context) { + this.macro = macro; + this.program = program; + this.context = context; + } + + public Macro macro() { + return macro; + } + + public boolean finished() { + return aborted || pc >= program.instructions().size(); + } + + public void run() { + try { + runUnsafe(); + } catch (Exception e) { + aborted = true; + LOGGER.error("Script macro '{}' failed at instruction {}", macro.name(), pc, e); + } + } + + private void runUnsafe() { + Minecraft client = context.client(); + int steps = 0; + while (!finished()) { + if (++steps > STEP_LIMIT) { + aborted = true; + LOGGER.error("Script macro '{}' exceeded {} steps in one tick, aborting", + macro.name(), STEP_LIMIT); + return; + } + Instruction instruction = program.instructions().get(pc); + switch (instruction.op) { + case Instruction.ACT -> { + ActionResult result = instruction.action.execute(context); + if (result.aborted()) { + aborted = true; + return; + } + pc++; + if (result.delayTicks() > 0) { + delay = result.delayTicks(); + return; + } + } + case Instruction.JUMP -> pc = instruction.target; + case Instruction.JUMP_IF_FALSE -> + pc = instruction.condition.test(client) ? pc + 1 : instruction.target; + default -> { + aborted = true; + return; + } + } + } + } + + public void tick() { + if (finished() || delay <= 0) { + return; + } + delay--; + if (delay <= 0) { + run(); + } + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java new file mode 100644 index 0000000..1bb6db6 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java @@ -0,0 +1,103 @@ +package com.kingjayan.keybinds.dsl; + +import java.util.ArrayList; +import java.util.List; + +public final class ScriptLexer { + + public enum TokenType { + IDENT, INT, STRING, LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, COMMA, EQUALS, EOF + } + + public record Token(TokenType type, String text, int line) { + } + + private ScriptLexer() { + } + + public static List tokenize(String source) { + List tokens = new ArrayList<>(); + int line = 1; + int i = 0; + int length = source.length(); + while (i < length) { + char c = source.charAt(i); + if (c == '\n') { + line++; + i++; + } else if (c == ' ' || c == '\t' || c == '\r') { + i++; + } else if (c == '#') { + while (i < length && source.charAt(i) != '\n') { + i++; + } + } else if (c == '(') { + tokens.add(new Token(TokenType.LPAREN, "(", line)); + i++; + } else if (c == ')') { + tokens.add(new Token(TokenType.RPAREN, ")", line)); + i++; + } else if (c == '{') { + tokens.add(new Token(TokenType.LBRACE, "{", line)); + i++; + } else if (c == '}') { + tokens.add(new Token(TokenType.RBRACE, "}", line)); + i++; + } else if (c == '[') { + tokens.add(new Token(TokenType.LBRACKET, "[", line)); + i++; + } else if (c == ']') { + tokens.add(new Token(TokenType.RBRACKET, "]", line)); + i++; + } else if (c == ',') { + tokens.add(new Token(TokenType.COMMA, ",", line)); + i++; + } else if (c == '=') { + tokens.add(new Token(TokenType.EQUALS, "=", line)); + i++; + } else if (c == '"') { + int start = ++i; + StringBuilder value = new StringBuilder(); + boolean closed = false; + while (i < length) { + char d = source.charAt(i); + if (d == '"') { + closed = true; + i++; + break; + } + if (d == '\n') { + break; + } + value.append(d); + i++; + } + if (!closed) { + throw new ScriptException(line, "Unterminated string starting at column " + start); + } + tokens.add(new Token(TokenType.STRING, value.toString(), line)); + } else if (Character.isDigit(c) || (c == '-' && i + 1 < length && Character.isDigit(source.charAt(i + 1)))) { + int start = i; + i++; + while (i < length && Character.isDigit(source.charAt(i))) { + i++; + } + tokens.add(new Token(TokenType.INT, source.substring(start, i), line)); + } else if (isWordChar(c)) { + int start = i; + while (i < length && isWordChar(source.charAt(i))) { + i++; + } + tokens.add(new Token(TokenType.IDENT, source.substring(start, i), line)); + } else { + throw new ScriptException(line, "Unexpected character '" + c + "'"); + } + } + tokens.add(new Token(TokenType.EOF, "", line)); + return tokens; + } + + public static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == ':' || c == '.' || c == '/'; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java new file mode 100644 index 0000000..d3a8209 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java @@ -0,0 +1,230 @@ +package com.kingjayan.keybinds.dsl; + +import com.kingjayan.keybinds.dsl.ScriptAst.ActionStmt; +import com.kingjayan.keybinds.dsl.ScriptAst.AndExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Arg; +import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue; +import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Expr; +import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt; +import com.kingjayan.keybinds.dsl.ScriptAst.IntValue; +import com.kingjayan.keybinds.dsl.ScriptAst.ListValue; +import com.kingjayan.keybinds.dsl.ScriptAst.NotExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.OrExpr; +import com.kingjayan.keybinds.dsl.ScriptAst.Script; +import com.kingjayan.keybinds.dsl.ScriptAst.Stmt; +import com.kingjayan.keybinds.dsl.ScriptAst.StrValue; +import com.kingjayan.keybinds.dsl.ScriptAst.Value; +import com.kingjayan.keybinds.dsl.ScriptLexer.Token; +import com.kingjayan.keybinds.dsl.ScriptLexer.TokenType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public final class ScriptParser { + + private static final Set RESERVED = Set.of("when", "if", "else", "and", "or", "not", "true", "false"); + + private final List tokens; + private int position; + + private ScriptParser(List tokens) { + this.tokens = tokens; + } + + public static Script parse(String source) { + return new ScriptParser(ScriptLexer.tokenize(source)).parseScript(); + } + + private Script parseScript() { + Token first = peek(); + if (first.type() != TokenType.IDENT || !first.text().equals("when")) { + throw new ScriptException(first.line(), "Script must start with a 'when ' line"); + } + advance(); + Expr when = parseOr(); + List body = parseStatements(TokenType.EOF); + expect(TokenType.EOF, "end of script"); + return new Script(when, body); + } + + private List parseStatements(TokenType end) { + List statements = new ArrayList<>(); + while (peek().type() != end && peek().type() != TokenType.EOF) { + Token token = peek(); + if (token.type() != TokenType.IDENT) { + throw new ScriptException(token.line(), "Expected an action or 'if', found '" + token.text() + "'"); + } + if (token.text().equals("if")) { + statements.add(parseIf()); + } else if (token.text().equals("else")) { + throw new ScriptException(token.line(), "'else' without a matching 'if'"); + } else if (RESERVED.contains(token.text())) { + throw new ScriptException(token.line(), "'" + token.text() + "' is not allowed here"); + } else { + statements.add(new ActionStmt(parseCall())); + } + } + return statements; + } + + private IfStmt parseIf() { + int line = advance().line(); + Expr condition = parseOr(); + expect(TokenType.LBRACE, "'{' after if condition"); + List thenBody = parseStatements(TokenType.RBRACE); + expect(TokenType.RBRACE, "'}' to close if block"); + List elseBody = List.of(); + if (peek().type() == TokenType.IDENT && peek().text().equals("else")) { + advance(); + expect(TokenType.LBRACE, "'{' after else"); + elseBody = parseStatements(TokenType.RBRACE); + expect(TokenType.RBRACE, "'}' to close else block"); + } + return new IfStmt(condition, thenBody, elseBody, line); + } + + private Expr parseOr() { + Expr first = parseAnd(); + if (!matchesWord("or")) { + return first; + } + List children = new ArrayList<>(); + children.add(first); + while (matchesWord("or")) { + advance(); + children.add(parseAnd()); + } + return new OrExpr(children, first.line()); + } + + private Expr parseAnd() { + Expr first = parseUnary(); + if (!matchesWord("and")) { + return first; + } + List children = new ArrayList<>(); + children.add(first); + while (matchesWord("and")) { + advance(); + children.add(parseUnary()); + } + return new AndExpr(children, first.line()); + } + + private Expr parseUnary() { + Token token = peek(); + if (token.type() == TokenType.IDENT && token.text().equals("not")) { + advance(); + return new NotExpr(parseUnary(), token.line()); + } + if (token.type() == TokenType.LPAREN) { + advance(); + Expr inner = parseOr(); + expect(TokenType.RPAREN, "')' to close group"); + return inner; + } + if (token.type() == TokenType.IDENT) { + if (RESERVED.contains(token.text())) { + throw new ScriptException(token.line(), "Expected a trigger, found '" + token.text() + "'"); + } + return parseCall(); + } + throw new ScriptException(token.line(), "Expected a trigger, found '" + token.text() + "'"); + } + + private CallExpr parseCall() { + Token name = expect(TokenType.IDENT, "a name"); + List args = List.of(); + if (peek().type() == TokenType.LPAREN) { + advance(); + args = new ArrayList<>(); + if (peek().type() != TokenType.RPAREN) { + args.add(parseArg()); + while (peek().type() == TokenType.COMMA) { + advance(); + args.add(parseArg()); + } + } + expect(TokenType.RPAREN, "')' to close arguments"); + } + return new CallExpr(name.text(), args, name.line()); + } + + private Arg parseArg() { + if (peek().type() == TokenType.IDENT && peekAt(1).type() == TokenType.EQUALS + && !RESERVED.contains(peek().text())) { + String name = advance().text(); + advance(); + return new Arg(name, parseValue()); + } + return new Arg(null, parseValue()); + } + + private Value parseValue() { + Token token = peek(); + switch (token.type()) { + case INT -> { + advance(); + return new IntValue(Integer.parseInt(token.text())); + } + case STRING -> { + advance(); + return new StrValue(token.text(), false); + } + case IDENT -> { + advance(); + if (token.text().equals("true") || token.text().equals("false")) { + return new BoolValue(token.text().equals("true")); + } + return new StrValue(token.text(), true); + } + case LBRACKET -> { + advance(); + List values = new ArrayList<>(); + while (peek().type() != TokenType.RBRACKET) { + Token item = peek(); + if (item.type() != TokenType.STRING && item.type() != TokenType.IDENT) { + throw new ScriptException(item.line(), "Lists may only contain names or strings"); + } + advance(); + values.add(item.text()); + if (peek().type() == TokenType.COMMA) { + advance(); + } + } + expect(TokenType.RBRACKET, "']' to close list"); + return new ListValue(values); + } + default -> throw new ScriptException(token.line(), "Expected a value, found '" + token.text() + "'"); + } + } + + private boolean matchesWord(String word) { + Token token = peek(); + return token.type() == TokenType.IDENT && token.text().equals(word); + } + + private Token peek() { + return tokens.get(position); + } + + private Token peekAt(int offset) { + int index = Math.min(position + offset, tokens.size() - 1); + return tokens.get(index); + } + + private Token advance() { + return tokens.get(position++); + } + + private Token expect(TokenType type, String what) { + Token token = peek(); + if (token.type() != type) { + throw new ScriptException(token.line(), "Expected " + what + ", found '" + + (token.type() == TokenType.EOF ? "end of script" : token.text()) + "'"); + } + return advance(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java new file mode 100644 index 0000000..3277b8d --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java @@ -0,0 +1,157 @@ +package com.kingjayan.keybinds.dsl; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.ui.editor.EditorSpecs; +import net.minecraft.util.GsonHelper; + +import java.util.List; +import java.util.StringJoiner; + +public final class ScriptPrinter { + + private ScriptPrinter() { + } + + public static String print(Macro macro) { + StringBuilder out = new StringBuilder(); + out.append("# ").append(macro.name()).append('\n'); + if (macro.trigger() == null) { + out.append("# set a trigger: when key(r), when sneaking and holding(\"minecraft:mace\"), ...\n"); + out.append("when key()\n"); + } else { + out.append("when ").append(expr(TriggerRegistry.toJson(macro.trigger()), 0)).append('\n'); + } + out.append('\n'); + if (macro.actions().isEmpty()) { + out.append("# add actions below, one per line\n"); + } + for (Action action : macro.actions()) { + out.append(statement(ActionRegistry.toJson(action))).append('\n'); + } + return out.toString(); + } + + private static String expr(JsonObject json, int depth) { + String type = GsonHelper.getAsString(json, "type", ""); + return switch (type) { + case "all_of" -> joinChildren(json, " and ", depth, 2); + case "any_of" -> joinChildren(json, " or ", depth, 1); + case "not" -> { + JsonObject child = json.get("child") instanceof JsonObject c ? c : null; + yield child == null ? "not key()" : "not " + wrapComposite(child, depth + 1); + } + case "key" -> "key(" + keyArg(json, "key", true) + ")"; + case "key_release" -> "key_release(" + keyArg(json, "key", true) + ")"; + case "mouse_button" -> "mouse(" + keyArg(json, "button", false) + ")"; + case "mouse_release" -> "mouse_release(" + keyArg(json, "button", false) + ")"; + case "condition" -> GsonHelper.getAsString(json, "condition", "low_health"); + case "holding" -> "holding(" + quotedList(json, "items") + ")"; + case "slot_selected" -> "slot_selected(" + GsonHelper.getAsInt(json, "slot", 0) + ")"; + case "riding" -> "riding"; + case "held_durability" -> "held_durability(" + GsonHelper.getAsInt(json, "durability", 10) + ")"; + default -> type; + }; + } + + private static String joinChildren(JsonObject json, String separator, int depth, int precedence) { + StringJoiner joiner = new StringJoiner(separator); + for (JsonElement element : GsonHelper.getAsJsonArray(json, "children", new JsonArray())) { + if (element instanceof JsonObject child) { + String childType = GsonHelper.getAsString(child, "type", ""); + boolean parens = (precedence == 2 && childType.equals("any_of")) + || (depth > 0 && (childType.equals("any_of") || childType.equals("all_of"))); + joiner.add(parens ? "(" + expr(child, depth + 1) + ")" : expr(child, depth + 1)); + } + } + String joined = joiner.toString(); + return joined.isEmpty() ? "key()" : joined; + } + + private static String wrapComposite(JsonObject child, int depth) { + String type = GsonHelper.getAsString(child, "type", ""); + String text = expr(child, depth); + return type.equals("all_of") || type.equals("any_of") ? "(" + text + ")" : text; + } + + private static String keyArg(JsonObject json, String field, boolean keyboard) { + int code = GsonHelper.getAsInt(json, field, -1); + if (code < 0) { + return ""; + } + String name = keyboard ? KeyNames.keyName(code) : KeyNames.buttonName(code); + return name != null ? name : String.valueOf(code); + } + + private static String quotedList(JsonObject json, String field) { + StringJoiner joiner = new StringJoiner(", "); + for (String item : EditorSpecs.readStringList(json, field)) { + joiner.add("\"" + item + "\""); + } + return joiner.toString(); + } + + private static String statement(JsonObject json) { + String type = GsonHelper.getAsString(json, "type", ""); + EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(type); + if (spec == null) { + return type; + } + StringJoiner args = new StringJoiner(", "); + boolean positional = spec.params().size() == 1; + for (EditorSpecs.ParamSpec param : spec.params()) { + String value = paramValue(json, param); + if (value == null) { + continue; + } + args.add(positional ? value : param.key() + "=" + value); + } + String joined = args.toString(); + return joined.isEmpty() ? type : type + "(" + joined + ")"; + } + + private static String paramValue(JsonObject json, EditorSpecs.ParamSpec param) { + switch (param.kind()) { + case INT -> { + int value = GsonHelper.getAsInt(json, param.key(), Integer.parseInt(param.def())); + return value == Integer.parseInt(param.def()) ? null : String.valueOf(value); + } + case KEY, MOUSE -> { + int value = GsonHelper.getAsInt(json, param.key(), -1); + if (value < 0) { + return null; + } + String name = param.kind() == EditorSpecs.Kind.KEY + ? KeyNames.keyName(value) : KeyNames.buttonName(value); + return name != null ? name : String.valueOf(value); + } + case BOOL -> { + boolean value = GsonHelper.getAsBoolean(json, param.key(), Boolean.parseBoolean(param.def())); + return value == Boolean.parseBoolean(param.def()) ? null : String.valueOf(value); + } + case STRING -> { + String value = GsonHelper.getAsString(json, param.key(), param.def()); + return value.equals(param.def()) || value.isEmpty() ? null : "\"" + value + "\""; + } + case ENUM -> { + String value = GsonHelper.getAsString(json, param.key(), param.def()); + return value.equals(param.def()) ? null : value; + } + case STRING_LIST -> { + List values = EditorSpecs.readStringList(json, param.key()); + if (values.isEmpty()) { + return null; + } + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + values.forEach(v -> joiner.add("\"" + v + "\"")); + return joiner.toString(); + } + } + return null; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java new file mode 100644 index 0000000..486fe5e --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java @@ -0,0 +1,151 @@ +package com.kingjayan.keybinds.dsl; + +import com.kingjayan.keybinds.core.action.Action; +import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.core.trigger.Trigger; +import com.kingjayan.keybinds.game.ItemLookup; +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.Minecraft; +import org.lwjgl.glfw.GLFW; + +import java.util.List; + +public record ScriptProgram(Trigger trigger, List instructions) { + + public static final class Instruction { + + public static final int ACT = 0; + public static final int JUMP = 1; + public static final int JUMP_IF_FALSE = 2; + + public final int op; + public final Action action; + public final Condition condition; + public int target; + + private Instruction(int op, Action action, Condition condition, int target) { + this.op = op; + this.action = action; + this.condition = condition; + this.target = target; + } + + public static Instruction act(Action action) { + return new Instruction(ACT, action, null, -1); + } + + public static Instruction jump() { + return new Instruction(JUMP, null, null, -1); + } + + public static Instruction jumpIfFalse(Condition condition) { + return new Instruction(JUMP_IF_FALSE, null, condition, -1); + } + } + + public sealed interface Condition permits AllCondition, AnyCondition, NotCondition, KeyDownCondition, + MouseDownCondition, PlayerCondition, HoldingCondition, RidingCondition, SlotCondition, + DurabilityCondition { + + boolean test(Minecraft client); + } + + public record AllCondition(List children) implements Condition { + + @Override + public boolean test(Minecraft client) { + for (Condition child : children) { + if (!child.test(client)) { + return false; + } + } + return true; + } + } + + public record AnyCondition(List children) implements Condition { + + @Override + public boolean test(Minecraft client) { + for (Condition child : children) { + if (child.test(client)) { + return true; + } + } + return false; + } + } + + public record NotCondition(Condition child) implements Condition { + + @Override + public boolean test(Minecraft client) { + return !child.test(client); + } + } + + public record KeyDownCondition(int key) implements Condition { + + @Override + public boolean test(Minecraft client) { + return key >= 0 && client != null && InputConstants.isKeyDown(client.getWindow(), key); + } + } + + public record MouseDownCondition(int button) implements Condition { + + @Override + public boolean test(Minecraft client) { + return button >= 0 && client != null + && GLFW.glfwGetMouseButton(client.getWindow().handle(), button) == GLFW.GLFW_PRESS; + } + } + + public record PlayerCondition(String id) implements Condition { + + @Override + public boolean test(Minecraft client) { + return client != null && PlayerConditions.active(id, client.player); + } + } + + public record HoldingCondition(List items) implements Condition { + + @Override + public boolean test(Minecraft client) { + if (client == null || client.player == null) { + return false; + } + return items.isEmpty() || ItemLookup.matchesAny(client.player.getMainHandItem(), items); + } + } + + public record RidingCondition() implements Condition { + + @Override + public boolean test(Minecraft client) { + return client != null && client.player != null && client.player.isPassenger(); + } + } + + public record SlotCondition(int slot) implements Condition { + + @Override + public boolean test(Minecraft client) { + return client != null && client.player != null + && client.player.getInventory().getSelectedSlot() == slot; + } + } + + public record DurabilityCondition(int durability) implements Condition { + + @Override + public boolean test(Minecraft client) { + if (client == null || client.player == null) { + return false; + } + var held = client.player.getMainHandItem(); + return held.isDamageableItem() && held.getMaxDamage() - held.getDamageValue() <= durability; + } + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java new file mode 100644 index 0000000..a86adae --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java @@ -0,0 +1,89 @@ +package com.kingjayan.keybinds.dsl; + +import com.kingjayan.keybinds.core.action.ActionContext; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.schedule.TickScheduler; +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public final class ScriptRuntime { + + private static final Logger LOGGER = LoggerFactory.getLogger("keybinds"); + + private final List executions = new ArrayList<>(); + private final Map compiled = new HashMap<>(); + + private record CompiledScript(String source, ScriptProgram program, String error, int errorLine) { + } + + public void recompile(List macros) { + compiled.keySet().removeIf(id -> macros.stream() + .noneMatch(macro -> macro.script() != null && macro.id().equals(id))); + for (Macro macro : macros) { + if (macro.script() == null) { + continue; + } + CompiledScript entry = compiled.get(macro.id()); + if (entry == null || !entry.source.equals(macro.script())) { + entry = compileEntry(macro); + compiled.put(macro.id(), entry); + } + macro.setTrigger(entry.program != null ? entry.program.trigger() : null); + } + } + + private CompiledScript compileEntry(Macro macro) { + try { + return new CompiledScript(macro.script(), ScriptCompiler.compile(macro.script()), null, -1); + } catch (ScriptException e) { + LOGGER.warn("Script macro '{}' failed to compile (line {}): {}", + macro.name(), e.line(), e.getMessage()); + return new CompiledScript(macro.script(), null, e.getMessage(), e.line()); + } catch (Exception e) { + LOGGER.error("Script macro '{}' failed to compile", macro.name(), e); + return new CompiledScript(macro.script(), null, e.toString(), -1); + } + } + + public void tick() { + if (executions.isEmpty()) { + return; + } + for (int i = 0; i < executions.size(); i++) { + executions.get(i).tick(); + } + executions.removeIf(ScriptExecution::finished); + } + + public void start(Macro macro, Minecraft client, TickScheduler scheduler) { + CompiledScript entry = compiled.get(macro.id()); + if (entry == null || entry.program == null || running(macro)) { + return; + } + LocalPlayer player = client.player; + int startSlot = player != null ? player.getInventory().getSelectedSlot() : -1; + ScriptExecution execution = new ScriptExecution(macro, entry.program, + new ActionContext(client, scheduler, startSlot)); + execution.run(); + if (!execution.finished()) { + executions.add(execution); + } + } + + private boolean running(Macro macro) { + for (ScriptExecution execution : executions) { + if (execution.macro() == macro && !execution.finished()) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java index 4496d65..b7fe80b 100644 --- a/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java +++ b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java @@ -60,6 +60,10 @@ public static JsonObject writeMacro(Macro macro) { json.addProperty("id", macro.id().toString()); json.addProperty("name", macro.name()); json.addProperty("enabled", macro.enabled()); + if (macro.script() != null) { + json.addProperty("script", macro.script()); + return json; + } if (macro.trigger() != null) { json.add("trigger", TriggerRegistry.toJson(macro.trigger())); } @@ -86,7 +90,11 @@ public static Macro readMacro(JsonObject json) { } } } - return new Macro(id, name, enabled, trigger, actions); + Macro macro = new Macro(id, name, enabled, trigger, actions); + if (json.get("script") != null && json.get("script").isJsonPrimitive()) { + macro.setScript(json.get("script").getAsString()); + } + return macro; } private static UUID parseId(String raw) { diff --git a/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java new file mode 100644 index 0000000..78edfb3 --- /dev/null +++ b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java @@ -0,0 +1,156 @@ +package com.kingjayan.keybinds; + +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.action.ActionContext; +import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.action.ActionTypes; +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.core.trigger.TriggerTypes; +import com.kingjayan.keybinds.dsl.ScriptCompiler; +import com.kingjayan.keybinds.dsl.ScriptException; +import com.kingjayan.keybinds.dsl.ScriptExecution; +import com.kingjayan.keybinds.dsl.ScriptPrinter; +import com.kingjayan.keybinds.dsl.ScriptProgram; +import com.kingjayan.keybinds.serialize.MacroCodec; +import com.kingjayan.keybinds.ui.editor.EditorSpecs; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.lwjgl.glfw.GLFW; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScriptDslTest { + + @BeforeAll + static void bootstrap() { + ActionTypes.bootstrap(); + TriggerTypes.bootstrap(); + } + + @Test + void compilesTriggersAndActions() { + ScriptProgram program = ScriptCompiler.compile(""" + when key(r) and (sneaking or holding("minecraft:mace")) and not riding + select_slot(3) + wait(2) + attack(swing=true) + use_item(item="minecraft:ender_pearl", useDelay=5) + """); + assertNotNull(program.trigger()); + assertEquals(4, program.instructions().size()); + JsonObject json = TriggerRegistry.toJson(program.trigger()); + assertEquals("all_of", json.get("type").getAsString()); + assertEquals(3, json.getAsJsonArray("children").size()); + assertEquals(GLFW.GLFW_KEY_R, + json.getAsJsonArray("children").get(0).getAsJsonObject().get("key").getAsInt()); + } + + @Test + void ifElseCompilesToJumps() { + ScriptProgram program = ScriptCompiler.compile(""" + when key(g) + if riding { + attack + } else { + use + use + } + wait(1) + """); + List instructions = program.instructions(); + assertEquals(6, instructions.size()); + assertEquals(ScriptProgram.Instruction.JUMP_IF_FALSE, instructions.get(0).op); + assertEquals(3, instructions.get(0).target); + assertEquals(ScriptProgram.Instruction.JUMP, instructions.get(2).op); + assertEquals(5, instructions.get(2).target); + } + + @Test + void runtimeBranchesAndWaits() { + ScriptProgram program = ScriptCompiler.compile(""" + when key(g) + if riding { + wait(50) + } else { + wait(2) + } + wait(3) + """); + Macro macro = new Macro(UUID.randomUUID(), "test", true, null, List.of()); + ScriptExecution execution = new ScriptExecution(macro, program, + new ActionContext(null, new TickScheduler(), -1)); + execution.run(); + assertFalse(execution.finished()); + execution.tick(); + assertFalse(execution.finished()); + execution.tick(); + assertTrue(execution.finished()); + } + + @Test + void visualMacroConvertsAndRecompiles() { + JsonObject json = new JsonObject(); + json.addProperty("id", UUID.randomUUID().toString()); + json.addProperty("name", "converted"); + JsonObject trigger = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("all_of")); + JsonObject key = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key")); + key.addProperty("key", GLFW.GLFW_KEY_F); + JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not")); + not.add("child", EditorSpecs.defaultJson(EditorSpecs.triggerSpec("riding"))); + trigger.getAsJsonArray("children").add(key); + trigger.getAsJsonArray("children").add(not); + json.add("trigger", trigger); + com.google.gson.JsonArray actions = new com.google.gson.JsonArray(); + for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) { + actions.add(EditorSpecs.defaultJson(spec)); + } + json.add("actions", actions); + Macro macro = MacroCodec.readMacro(json); + + String script = ScriptPrinter.print(macro); + ScriptProgram program = ScriptCompiler.compile(script); + assertEquals(TriggerRegistry.toJson(macro.trigger()), TriggerRegistry.toJson(program.trigger())); + assertEquals(macro.actions().size(), program.instructions().size()); + for (int i = 0; i < macro.actions().size(); i++) { + assertEquals(ActionRegistry.toJson(macro.actions().get(i)), + ActionRegistry.toJson(program.instructions().get(i).action)); + } + } + + @Test + void scriptFieldRoundTripsThroughCodec() { + Macro macro = new Macro(UUID.randomUUID(), "scripted", true, null, List.of()); + macro.setScript("when key(r)\nattack\n"); + JsonObject written = MacroCodec.writeMacro(macro); + assertFalse(written.has("trigger")); + assertFalse(written.has("actions")); + Macro reloaded = MacroCodec.readMacro(written); + assertEquals(macro.script(), reloaded.script()); + assertEquals(written, MacroCodec.writeMacro(reloaded)); + } + + @Test + void reportsErrorsWithLineNumbers() { + assertEquals(1, assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("attack")).line()); + assertEquals(2, assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("when key(r)\nexplode")).line()); + assertEquals(3, assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("when key(r)\nattack\nif key_release(r) { use }")).line()); + assertEquals(2, assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("when key(r)\nuse_item(hotbarGate=sometimes)")).line()); + assertEquals(3, assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("when key(r)\nif riding {\nattack")).line()); + assertThrows(ScriptException.class, + () -> ScriptCompiler.compile("when key(banana)\nattack")); + } +} From 27ae58f343788ad73c5fbdb70e5c443a9a58a50d Mon Sep 17 00:00:00 2001 From: KingJayan Date: Mon, 13 Jul 2026 17:41:08 -0500 Subject: [PATCH 16/19] add one-way text editing mode with syntax highlighting, autocomplete, and in-game docs --- gradle.properties | 4 +- .../keybinds/ui/editor/CodeEditor.java | 540 ++++++++++++++++++ .../keybinds/ui/editor/MacroEditScreen.java | 41 +- .../keybinds/ui/editor/MacroListScreen.java | 7 +- .../keybinds/ui/editor/ScriptEditScreen.java | 135 +++++ .../resources/assets/keybinds/lang/en_us.json | 6 + 6 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java create mode 100644 src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java diff --git a/gradle.properties b/gradle.properties index dfa120c..91e8760 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Done to increase the memory available to gradle. -org.gradle.jvmargs=-Xmx1G +org.gradle.jvmargs=-Xmx2G org.gradle.parallel=true # IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349 @@ -12,7 +12,7 @@ loader_version=0.19.3 loom_version=1.17-SNAPSHOT # Mod Properties -mod_version=1.1.0 +mod_version=2.0.0-beta.1 maven_group=com.kingjayan # Dependencies diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java new file mode 100644 index 0000000..e22b5e8 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java @@ -0,0 +1,540 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.dsl.ScriptDocs; +import com.kingjayan.keybinds.dsl.ScriptLexer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +public class CodeEditor extends AbstractWidget { + + private static final int LINE_HEIGHT = 10; + private static final int GUTTER = 24; + private static final int PADDING = 4; + private static final int MAX_SUGGESTIONS = 6; + + private static final int BACKGROUND = 0xF0101018; + private static final int BORDER = 0xFF404060; + private static final int GUTTER_COLOR = 0xFF606070; + private static final int ERROR_TINT = 0x50FF2020; + private static final int CURSOR_COLOR = 0xFFFFFFFF; + private static final int POPUP_BG = 0xF8181828; + private static final int POPUP_BORDER = 0xFF5000A0; + private static final int POPUP_HOVER = 0x40FFFFFF; + + private static final int COLOR_DEFAULT = 0xFFF0F0F0; + private static final int COLOR_KEYWORD = 0xFFFFAA55; + private static final int COLOR_TRIGGER = 0xFF66D9EF; + private static final int COLOR_ACTION = 0xFFA6E22E; + private static final int COLOR_STRING = 0xFFE6DB74; + private static final int COLOR_NUMBER = 0xFFAE81FF; + private static final int COLOR_COMMENT = 0xFF75715E; + private static final int COLOR_PUNCT = 0xFF9090A0; + + private static final Set KEYWORDS = Set.of("when", "if", "else", "and", "or", "not", "true", "false"); + private static final Set TRIGGER_WORDS = new HashSet<>(); + private static final Set ACTION_WORDS = new HashSet<>(); + + static { + for (EditorSpecs.TypeSpec spec : EditorSpecs.triggerTypes()) { + TRIGGER_WORDS.add(spec.id()); + } + TRIGGER_WORDS.add("mouse"); + TRIGGER_WORDS.addAll(PlayerConditions.ids()); + for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) { + ACTION_WORDS.add(spec.id()); + } + } + + private final Font font; + private final Consumer onChange; + private final List lines = new ArrayList<>(); + + private int cursorLine; + private int cursorCol; + private int scrollLine; + private int scrollX; + private int errorLine = -1; + private List suggestions = List.of(); + private int suggestionIndex; + + public CodeEditor(int x, int y, int width, int height, Font font, Consumer onChange) { + super(x, y, width, height, Component.empty()); + this.font = font; + this.onChange = onChange; + lines.add(new StringBuilder()); + } + + public void setText(String text) { + lines.clear(); + for (String line : text.split("\n", -1)) { + lines.add(new StringBuilder(line)); + } + if (lines.isEmpty()) { + lines.add(new StringBuilder()); + } + cursorLine = 0; + cursorCol = 0; + scrollLine = 0; + scrollX = 0; + suggestions = List.of(); + } + + public String text() { + StringBuilder out = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + out.append('\n'); + } + out.append(lines.get(i)); + } + return out.toString(); + } + + public void setErrorLine(int line) { + errorLine = line; + } + + private void changed() { + onChange.accept(text()); + updateSuggestions(); + ensureCursorVisible(); + } + + private int visibleLines() { + return (height - PADDING * 2) / LINE_HEIGHT; + } + + private void ensureCursorVisible() { + if (cursorLine < scrollLine) { + scrollLine = cursorLine; + } + if (cursorLine >= scrollLine + visibleLines()) { + scrollLine = cursorLine - visibleLines() + 1; + } + int textWidth = width - GUTTER - PADDING * 2; + int cursorX = font.width(lines.get(cursorLine).substring(0, cursorCol)); + if (cursorX < scrollX) { + scrollX = Math.max(0, cursorX - 20); + } + if (cursorX > scrollX + textWidth - 10) { + scrollX = cursorX - textWidth + 30; + } + } + + private void updateSuggestions() { + String prefix = currentWord(); + if (prefix.isEmpty()) { + suggestions = List.of(); + return; + } + suggestions = ScriptDocs.completions(inTriggerContext(), prefix); + if (suggestions.size() > MAX_SUGGESTIONS) { + suggestions = suggestions.subList(0, MAX_SUGGESTIONS); + } + suggestionIndex = 0; + } + + private String currentWord() { + StringBuilder line = lines.get(cursorLine); + int start = cursorCol; + while (start > 0 && isNameChar(line.charAt(start - 1))) { + start--; + } + String word = line.substring(start, cursorCol); + return word.chars().allMatch(c -> Character.isLetter(c) || c == '_') ? word : ""; + } + + private static boolean isNameChar(char c) { + return ScriptLexer.isWordChar(c); + } + + private boolean inTriggerContext() { + String before = textBeforeWord(); + int i = before.length() - 1; + while (i >= 0 && Character.isWhitespace(before.charAt(i))) { + i--; + } + if (i < 0) { + return false; + } + char c = before.charAt(i); + if (c == '(') { + return true; + } + if (!isNameChar(c)) { + return false; + } + int end = i + 1; + while (i >= 0 && isNameChar(before.charAt(i))) { + i--; + } + String word = before.substring(i + 1, end); + return word.equals("when") || word.equals("if") || word.equals("and") + || word.equals("or") || word.equals("not"); + } + + private String textBeforeWord() { + StringBuilder line = lines.get(cursorLine); + int start = cursorCol; + while (start > 0 && isNameChar(line.charAt(start - 1))) { + start--; + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < cursorLine; i++) { + out.append(lines.get(i)).append('\n'); + } + out.append(line, 0, start); + return out.toString(); + } + + private void acceptSuggestion() { + ScriptDocs.Entry entry = suggestions.get(suggestionIndex); + String word = currentWord(); + StringBuilder line = lines.get(cursorLine); + String remainder = entry.name().substring(word.length()); + line.insert(cursorCol, remainder); + cursorCol += remainder.length(); + suggestions = List.of(); + onChange.accept(text()); + ensureCursorVisible(); + } + + @Override + public boolean charTyped(CharacterEvent event) { + if (!isFocused()) { + return false; + } + if (event.codepoint() < 32 || event.codepoint() == 127) { + return false; + } + String chars = event.codepointAsString(); + StringBuilder line = lines.get(cursorLine); + line.insert(cursorCol, chars); + cursorCol += chars.length(); + changed(); + return true; + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (!isFocused()) { + return false; + } + if (!suggestions.isEmpty()) { + switch (event.key()) { + case GLFW.GLFW_KEY_DOWN -> { + suggestionIndex = (suggestionIndex + 1) % suggestions.size(); + return true; + } + case GLFW.GLFW_KEY_UP -> { + suggestionIndex = (suggestionIndex + suggestions.size() - 1) % suggestions.size(); + return true; + } + case GLFW.GLFW_KEY_TAB, GLFW.GLFW_KEY_ENTER -> { + acceptSuggestion(); + return true; + } + case GLFW.GLFW_KEY_ESCAPE -> { + suggestions = List.of(); + return true; + } + default -> { + } + } + } + if (event.hasControlDown()) { + switch (event.key()) { + case GLFW.GLFW_KEY_V -> { + paste(); + return true; + } + case GLFW.GLFW_KEY_C -> { + Minecraft.getInstance().keyboardHandler.setClipboard(text()); + return true; + } + default -> { + } + } + } + switch (event.key()) { + case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> { + StringBuilder line = lines.get(cursorLine); + String tail = line.substring(cursorCol); + line.setLength(cursorCol); + String indent = leadingSpaces(line.toString()); + lines.add(cursorLine + 1, new StringBuilder(indent + tail)); + cursorLine++; + cursorCol = indent.length(); + changed(); + return true; + } + case GLFW.GLFW_KEY_BACKSPACE -> { + if (cursorCol > 0) { + lines.get(cursorLine).deleteCharAt(--cursorCol); + } else if (cursorLine > 0) { + StringBuilder previous = lines.get(cursorLine - 1); + cursorCol = previous.length(); + previous.append(lines.remove(cursorLine)); + cursorLine--; + } + changed(); + return true; + } + case GLFW.GLFW_KEY_DELETE -> { + StringBuilder line = lines.get(cursorLine); + if (cursorCol < line.length()) { + line.deleteCharAt(cursorCol); + } else if (cursorLine < lines.size() - 1) { + line.append(lines.remove(cursorLine + 1)); + } + changed(); + return true; + } + case GLFW.GLFW_KEY_TAB -> { + lines.get(cursorLine).insert(cursorCol, " "); + cursorCol += 2; + changed(); + return true; + } + case GLFW.GLFW_KEY_LEFT -> { + if (cursorCol > 0) { + cursorCol--; + } else if (cursorLine > 0) { + cursorLine--; + cursorCol = lines.get(cursorLine).length(); + } + suggestions = List.of(); + ensureCursorVisible(); + return true; + } + case GLFW.GLFW_KEY_RIGHT -> { + if (cursorCol < lines.get(cursorLine).length()) { + cursorCol++; + } else if (cursorLine < lines.size() - 1) { + cursorLine++; + cursorCol = 0; + } + suggestions = List.of(); + ensureCursorVisible(); + return true; + } + case GLFW.GLFW_KEY_UP -> { + moveLine(-1); + return true; + } + case GLFW.GLFW_KEY_DOWN -> { + moveLine(1); + return true; + } + case GLFW.GLFW_KEY_HOME -> { + cursorCol = 0; + suggestions = List.of(); + ensureCursorVisible(); + return true; + } + case GLFW.GLFW_KEY_END -> { + cursorCol = lines.get(cursorLine).length(); + suggestions = List.of(); + ensureCursorVisible(); + return true; + } + default -> { + return false; + } + } + } + + private void moveLine(int delta) { + int target = cursorLine + delta; + if (target >= 0 && target < lines.size()) { + cursorLine = target; + cursorCol = Math.min(cursorCol, lines.get(cursorLine).length()); + } + suggestions = List.of(); + ensureCursorVisible(); + } + + private static String leadingSpaces(String line) { + int i = 0; + while (i < line.length() && line.charAt(i) == ' ') { + i++; + } + return line.substring(0, i); + } + + private void paste() { + String clipboard = Minecraft.getInstance().keyboardHandler.getClipboard(); + if (clipboard.isEmpty()) { + return; + } + String[] parts = clipboard.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); + StringBuilder line = lines.get(cursorLine); + String tail = line.substring(cursorCol); + line.setLength(cursorCol); + line.append(parts[0]); + for (int i = 1; i < parts.length; i++) { + lines.add(cursorLine + i, new StringBuilder(parts[i])); + } + cursorLine += parts.length - 1; + cursorCol = lines.get(cursorLine).length(); + lines.get(cursorLine).append(tail); + changed(); + } + + @Override + public void onClick(MouseButtonEvent event, boolean doubleClick) { + int line = scrollLine + ((int) event.y() - getY() - PADDING) / LINE_HEIGHT; + cursorLine = Math.clamp(line, 0, lines.size() - 1); + int targetX = (int) event.x() - (getX() + GUTTER + PADDING) + scrollX; + cursorCol = colAt(lines.get(cursorLine).toString(), targetX); + suggestions = List.of(); + } + + private int colAt(String line, int targetX) { + if (targetX <= 0) { + return 0; + } + int x = 0; + for (int i = 0; i < line.length(); i++) { + int w = font.width(String.valueOf(line.charAt(i))); + if (x + w / 2 >= targetX) { + return i; + } + x += w; + } + return line.length(); + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollXAmount, double scrollYAmount) { + if (!visible || !isMouseOver(x, y)) { + return false; + } + scrollLine = Math.clamp(scrollLine - (int) Math.signum(scrollYAmount) * 3, 0, + Math.max(0, lines.size() - visibleLines())); + return true; + } + + @Override + protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + int left = getX(); + int top = getY(); + graphics.fill(left - 1, top - 1, left + width + 1, top + height + 1, BORDER); + graphics.fill(left, top, left + width, top + height, BACKGROUND); + graphics.enableScissor(left, top, left + width, top + height); + int last = Math.min(lines.size(), scrollLine + visibleLines()); + for (int i = scrollLine; i < last; i++) { + int y = top + PADDING + (i - scrollLine) * LINE_HEIGHT; + if (i == errorLine) { + graphics.fill(left + 1, y - 1, left + width - 1, y + LINE_HEIGHT - 1, ERROR_TINT); + } + graphics.text(font, Component.literal(String.valueOf(i + 1)), left + 3, y, GUTTER_COLOR); + drawLine(graphics, lines.get(i).toString(), left + GUTTER + PADDING - scrollX, y); + } + if (isFocused() && cursorLine >= scrollLine && cursorLine < last + && (System.currentTimeMillis() / 500) % 2 == 0) { + int cx = left + GUTTER + PADDING - scrollX + + font.width(lines.get(cursorLine).substring(0, cursorCol)); + int cy = top + PADDING + (cursorLine - scrollLine) * LINE_HEIGHT; + graphics.fill(cx, cy - 1, cx + 1, cy + LINE_HEIGHT - 2, CURSOR_COLOR); + } + graphics.disableScissor(); + if (isFocused() && !suggestions.isEmpty()) { + drawSuggestions(graphics, left, top); + } + } + + private void drawLine(GuiGraphicsExtractor graphics, String line, int x, int y) { + int i = 0; + while (i < line.length()) { + char c = line.charAt(i); + int start = i; + int color; + if (c == '#') { + i = line.length(); + color = COLOR_COMMENT; + } else if (c == '"') { + i++; + while (i < line.length() && line.charAt(i) != '"') { + i++; + } + if (i < line.length()) { + i++; + } + color = COLOR_STRING; + } else if (Character.isDigit(c) || (c == '-' && i + 1 < line.length() + && Character.isDigit(line.charAt(i + 1)))) { + i++; + while (i < line.length() && Character.isDigit(line.charAt(i))) { + i++; + } + color = COLOR_NUMBER; + } else if (isNameChar(c)) { + while (i < line.length() && isNameChar(line.charAt(i))) { + i++; + } + String word = line.substring(start, i); + if (KEYWORDS.contains(word)) { + color = COLOR_KEYWORD; + } else if (TRIGGER_WORDS.contains(word)) { + color = COLOR_TRIGGER; + } else if (ACTION_WORDS.contains(word)) { + color = COLOR_ACTION; + } else { + color = COLOR_DEFAULT; + } + } else if (c == ' ' || c == '\t') { + i++; + x += font.width(line.substring(start, i)); + continue; + } else { + i++; + color = COLOR_PUNCT; + } + String segment = line.substring(start, i); + graphics.text(font, Component.literal(segment), x, y, color); + x += font.width(segment); + } + } + + private void drawSuggestions(GuiGraphicsExtractor graphics, int left, int top) { + int popupWidth = 0; + for (ScriptDocs.Entry entry : suggestions) { + popupWidth = Math.max(popupWidth, font.width(entry.name()) + 8); + } + int cx = left + GUTTER + PADDING - scrollX + + font.width(lines.get(cursorLine).substring(0, cursorCol)) - font.width(currentWord()); + int cy = top + PADDING + (cursorLine - scrollLine + 1) * LINE_HEIGHT + 1; + int popupHeight = suggestions.size() * LINE_HEIGHT + 4; + graphics.fill(cx - 1, cy - 1, cx + popupWidth + 1, cy + popupHeight + 1, POPUP_BORDER); + graphics.fill(cx, cy, cx + popupWidth, cy + popupHeight, POPUP_BG); + for (int i = 0; i < suggestions.size(); i++) { + int rowY = cy + 2 + i * LINE_HEIGHT; + if (i == suggestionIndex) { + graphics.fill(cx, rowY - 1, cx + popupWidth, rowY + LINE_HEIGHT - 1, POPUP_HOVER); + } + ScriptDocs.Entry entry = suggestions.get(i); + int color = entry.kind() == ScriptDocs.Kind.ACTION ? COLOR_ACTION : COLOR_TRIGGER; + graphics.text(font, Component.literal(entry.name()), cx + 4, rowY, color); + } + } + + @Override + protected void updateWidgetNarration(NarrationElementOutput output) { + } +} diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java index e8e41f6..945cf5b 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java @@ -6,14 +6,17 @@ import com.kingjayan.keybinds.core.macro.Macro; import com.kingjayan.keybinds.core.macro.MacroRegistry; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; +import com.kingjayan.keybinds.dsl.ScriptPrinter; import com.kingjayan.keybinds.persist.MacroStorage; import com.kingjayan.keybinds.serialize.MacroCodec; import com.kingjayan.keybinds.ui.framework.ContextMenu; import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ModalOverlay; import com.kingjayan.keybinds.ui.framework.ScrollPanel; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.CycleButton; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.MultiLineTextWidget; import net.minecraft.client.gui.components.StringWidget; import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; @@ -88,10 +91,14 @@ protected void init() { addRenderableWidget(new StringWidget(left, height - 58, COLUMN_WIDTH, 12, Component.translatable("keybinds.editor.shortcuts"), font)); addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.add_action"), b -> addAction()) - .bounds(width / 2 - 154, height - 28, 150, 20) + .bounds(width / 2 - 160, height - 28, 104, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.text_mode"), + b -> confirmTextMode()) + .bounds(width / 2 - 52, height - 28, 104, 20) .build()); addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) - .bounds(width / 2 + 4, height - 28, 150, 20) + .bounds(width / 2 + 56, height - 28, 104, 20) .build()); } @@ -380,6 +387,36 @@ private void editAction(int index) { })); } + private void confirmTextMode() { + ModalOverlay modal = new ModalOverlay(this, null); + int boxWidth = 260; + int left = width / 2 - boxWidth / 2; + int top = height / 2 - 40; + modal.add(new MultiLineTextWidget(left, top, + Component.translatable("keybinds.editor.text_mode_warning"), font).setMaxWidth(boxWidth)); + modal.add(Button.builder(Component.translatable("keybinds.editor.text_mode_confirm"), b -> { + modal.dismiss(); + convertToTextMode(); + }) + .bounds(left, top + 44, 126, 20) + .build()); + modal.add(Button.builder(Component.translatable("gui.cancel"), b -> modal.dismiss()) + .bounds(left + boxWidth - 126, top + 44, 126, 20) + .build()); + pushModal(modal); + } + + private void convertToTextMode() { + macro.setScript(ScriptPrinter.print(macro)); + macro.setTrigger(null); + macro.actions().clear(); + MacroStorage.save(MacroRegistry.get().all()); + MacroRegistry.get().touch(); + if (minecraft != null) { + minecraft.setScreenAndShow(new ScriptEditScreen(parent, macro)); + } + } + void refreshTrigger() { triggerSummary.setMessage(triggerText()); } diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java index 3adb3b6..5d10e50 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java @@ -72,7 +72,8 @@ private void addRow(Macro macro, int y) { panel.add(CycleButton.onOffBuilder(macro.enabled()) .displayOnlyValue() .create(0, 0, 44, 20, Component.empty(), (b, value) -> macro.setEnabled(value)), 0, y); - panel.add(Button.builder(Component.literal(macro.name()), b -> openEditor(macro)) + String label = macro.script() != null ? " " + macro.name() : macro.name(); + panel.add(Button.builder(Component.literal(label), b -> openEditor(macro)) .bounds(0, 0, ROW_WIDTH - 80, 20) .build(), 48, y); panel.add(Button.builder(Component.literal("✕"), b -> { @@ -112,6 +113,7 @@ private void duplicate(Macro macro) { Macro copy = MacroCodec.readMacro(MacroCodec.writeMacro(macro)); Macro fresh = new Macro(UUID.randomUUID(), copy.name() + " (copy)", copy.enabled(), copy.trigger(), copy.actions()); + fresh.setScript(copy.script()); MacroRegistry.get().add(fresh); rebuildWidgets(); } @@ -119,7 +121,8 @@ private void duplicate(Macro macro) { private void openEditor(Macro macro) { save(); if (minecraft != null) { - minecraft.setScreenAndShow(new MacroEditScreen(this, macro)); + minecraft.setScreenAndShow(macro.script() != null + ? new ScriptEditScreen(this, macro) : new MacroEditScreen(this, macro)); } } diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java new file mode 100644 index 0000000..6d42781 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java @@ -0,0 +1,135 @@ +package com.kingjayan.keybinds.ui.editor; + +import com.kingjayan.keybinds.core.macro.Macro; +import com.kingjayan.keybinds.core.macro.MacroRegistry; +import com.kingjayan.keybinds.dsl.ScriptCompiler; +import com.kingjayan.keybinds.dsl.ScriptDocs; +import com.kingjayan.keybinds.dsl.ScriptException; +import com.kingjayan.keybinds.persist.MacroStorage; +import com.kingjayan.keybinds.ui.framework.FrameworkScreen; +import com.kingjayan.keybinds.ui.framework.ScrollPanel; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.MultiLineTextWidget; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.network.chat.Component; + +public class ScriptEditScreen extends FrameworkScreen { + + private final MacroListScreen parent; + private final Macro macro; + + private CodeEditor editor; + private StringWidget status; + + public ScriptEditScreen(MacroListScreen parent, Macro macro) { + super(Component.translatable("keybinds.editor.script_title")); + this.parent = parent; + this.macro = macro; + } + + @Override + protected void init() { + int margin = 10; + int docsWidth = Math.min(200, width / 3); + int editorWidth = width - docsWidth - margin * 3; + int left = margin; + + addRenderableWidget(new StringWidget(0, 8, width, 20, title, font)); + + EditBox nameBox = new EditBox(font, left, 24, editorWidth - 52, 20, + Component.translatable("keybinds.editor.name")); + nameBox.setMaxLength(64); + nameBox.setValue(macro.name()); + nameBox.setResponder(value -> macro.setName(value.isBlank() ? "Unnamed" : value)); + addRenderableWidget(nameBox); + addRenderableWidget(CycleButton.onOffBuilder(macro.enabled()) + .displayOnlyValue() + .create(left + editorWidth - 44, 24, 44, 20, Component.empty(), + (b, value) -> macro.setEnabled(value))); + + editor = addRenderableWidget(new CodeEditor(left, 50, editorWidth, height - 106, font, + this::compileCheck)); + editor.setText(macro.script() == null ? "" : macro.script()); + + status = addRenderableWidget(new StringWidget(left, height - 50, editorWidth, 12, + Component.empty(), font)); + compileCheck(editor.text()); + + ScrollPanel docs = addRenderableWidget(new ScrollPanel(left + editorWidth + margin, 24, + docsWidth, height - 60)); + fillDocs(docs, docsWidth); + + addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose()) + .bounds(left, height - 28, 150, 20) + .build()); + } + + private void compileCheck(String text) { + if (status == null) { + return; + } + try { + ScriptCompiler.compile(text); + editor.setErrorLine(-1); + status.setMessage(Component.translatable("keybinds.editor.script_ok") + .withStyle(ChatFormatting.GREEN)); + } catch (ScriptException e) { + editor.setErrorLine(e.line() - 1); + status.setMessage(Component.literal("Line " + e.line() + ": " + e.getMessage()) + .withStyle(ChatFormatting.RED)); + } catch (Exception e) { + editor.setErrorLine(-1); + status.setMessage(Component.literal(String.valueOf(e.getMessage())) + .withStyle(ChatFormatting.RED)); + } + } + + private void fillDocs(ScrollPanel docs, int docsWidth) { + int textWidth = docsWidth - 12; + int y = 0; + for (ScriptDocs.Entry entry : ScriptDocs.entries()) { + switch (entry.kind()) { + case HEADER -> { + y += y == 0 ? 0 : 6; + y = addWrapped(docs, Component.literal(entry.name()) + .withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD), textWidth, y); + } + case TEXT -> { + y = addWrapped(docs, Component.literal(entry.description()) + .withStyle(ChatFormatting.GRAY), textWidth, y); + y += 3; + } + case TRIGGER, CONDITION, ACTION -> { + ChatFormatting color = entry.kind() == ScriptDocs.Kind.ACTION + ? ChatFormatting.GREEN : ChatFormatting.AQUA; + y = addWrapped(docs, Component.literal(entry.signature()).withStyle(color), + textWidth, y); + if (!entry.description().isEmpty()) { + y = addWrapped(docs, Component.literal(entry.description()) + .withStyle(ChatFormatting.GRAY), textWidth, y); + } + y += 3; + } + } + } + } + + private int addWrapped(ScrollPanel docs, Component text, int textWidth, int y) { + MultiLineTextWidget widget = new MultiLineTextWidget(0, 0, text, font).setMaxWidth(textWidth); + docs.add(widget, 4, y); + return y + widget.getHeight(); + } + + @Override + public void onClose() { + macro.setScript(editor.text()); + MacroStorage.save(MacroRegistry.get().all()); + MacroRegistry.get().touch(); + if (minecraft != null) { + minecraft.setScreenAndShow(parent); + } + } +} diff --git a/src/main/resources/assets/keybinds/lang/en_us.json b/src/main/resources/assets/keybinds/lang/en_us.json index 66e30c6..06ee48a 100644 --- a/src/main/resources/assets/keybinds/lang/en_us.json +++ b/src/main/resources/assets/keybinds/lang/en_us.json @@ -17,6 +17,12 @@ "keybinds.editor.paste": "Paste below", "keybinds.editor.shortcuts": "Drag ≡ to reorder · Ctrl+click selects · Ctrl+C/V, Ctrl+Z/Y, Del", + "keybinds.editor.text_mode": "Text Mode…", + "keybinds.editor.text_mode_warning": "Convert this macro to text editing mode? The current trigger and actions become an editable script. This cannot be undone — the macro can never go back to the visual editor.", + "keybinds.editor.text_mode_confirm": "Convert", + "keybinds.editor.script_title": "Edit Script", + "keybinds.editor.script_ok": "Script OK", + "keybinds.editor.trigger_title": "Edit Trigger", "keybinds.editor.set_trigger": "+ Set trigger", "keybinds.editor.trigger_hint": "AND/OR groups nest triggers. ✎ edits, + adds a child, ✕ removes." From 55cfdfb6fdff9f05aea16fdee7fe7b4b79e616b1 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Mon, 13 Jul 2026 18:15:41 -0500 Subject: [PATCH 17/19] add key combo trigger, camera, movement, and random wait actions --- .../keybinds/core/action/ActionTypes.java | 28 +++++ .../core/action/DoubleJumpAction.java | 29 +++++ .../keybinds/core/action/JumpAction.java | 27 +++++ .../keybinds/core/action/LookAction.java | 39 +++++++ .../keybinds/core/action/MoveAction.java | 40 +++++++ .../core/action/RandomWaitAction.java | 26 +++++ .../keybinds/core/action/SneakAction.java | 32 ++++++ .../keybinds/core/action/SprintAction.java | 33 ++++++ .../keybinds/core/action/TurnAction.java | 27 +++++ .../{dsl => core/input}/KeyNames.java | 2 +- .../core/trigger/KeyComboTrigger.java | 101 ++++++++++++++++++ .../keybinds/core/trigger/TriggerIndex.java | 7 ++ .../keybinds/core/trigger/TriggerTypes.java | 9 +- .../keybinds/ui/editor/EditorSpecs.java | 37 ++++++- .../keybinds/ui/editor/ParamModal.java | 17 +++ 15 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/LookAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java create mode 100644 src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java rename src/main/java/com/kingjayan/keybinds/{dsl => core/input}/KeyNames.java (98%) create mode 100644 src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java index 0c1ce46..4053b3d 100644 --- a/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java +++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java @@ -49,6 +49,34 @@ public final class ActionTypes { public static final ActionType SHIELD_TOTEM_SWAP = ActionRegistry.register(new ActionType("shield_totem_swap", json -> new ShieldTotemSwapAction())); + public static final ActionType LOOK = ActionRegistry.register(new ActionType("look", + json -> new LookAction(GsonHelper.getAsString(json, "direction", "north")))); + + public static final ActionType TURN = ActionRegistry.register(new ActionType("turn", + json -> new TurnAction(GsonHelper.getAsFloat(json, "degrees", 0.0F)))); + + public static final ActionType SPRINT = ActionRegistry.register(new ActionType("sprint", + json -> new SprintAction(GsonHelper.getAsString(json, "mode", "on")))); + + public static final ActionType SNEAK = ActionRegistry.register(new ActionType("sneak", + json -> new SneakAction(GsonHelper.getAsString(json, "mode", "on")))); + + public static final ActionType JUMP = ActionRegistry.register(new ActionType("jump", + json -> new JumpAction())); + + public static final ActionType DOUBLE_JUMP = ActionRegistry.register(new ActionType("double_jump", + json -> new DoubleJumpAction())); + + public static final ActionType MOVE = ActionRegistry.register(new ActionType("move", + json -> new MoveAction( + GsonHelper.getAsString(json, "direction", "forward"), + GsonHelper.getAsFloat(json, "blocks", 1.0F)))); + + public static final ActionType RANDOM_WAIT = ActionRegistry.register(new ActionType("random_wait", + json -> new RandomWaitAction( + GsonHelper.getAsInt(json, "min", 1), + GsonHelper.getAsInt(json, "max", 10)))); + private ActionTypes() { } diff --git a/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java b/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java new file mode 100644 index 0000000..af1878e --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java @@ -0,0 +1,29 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.KeyMapping; + +public record DoubleJumpAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.DOUBLE_JUMP; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public ActionResult execute(ActionContext ctx) { + if (ctx.client() == null || ctx.client().player == null) { + return ActionResult.proceed(); + } + KeyMapping jump = ctx.client().options.keyJump; + jump.setDown(true); + ctx.scheduler().schedule(1, () -> jump.setDown(false)); + ctx.scheduler().schedule(4, () -> jump.setDown(true)); + ctx.scheduler().schedule(5, () -> jump.setDown(false)); + return ActionResult.delay(5); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java b/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java new file mode 100644 index 0000000..36c7f81 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java @@ -0,0 +1,27 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.KeyMapping; + +public record JumpAction() implements Action { + + @Override + public ActionType type() { + return ActionTypes.JUMP; + } + + @Override + public void write(JsonObject out) { + } + + @Override + public ActionResult execute(ActionContext ctx) { + if (ctx.client() == null || ctx.client().player == null) { + return ActionResult.proceed(); + } + KeyMapping jump = ctx.client().options.keyJump; + jump.setDown(true); + ctx.scheduler().schedule(1, () -> jump.setDown(false)); + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java b/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java new file mode 100644 index 0000000..afaf220 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java @@ -0,0 +1,39 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; + +public record LookAction(String direction) implements Action { + + @Override + public ActionType type() { + return ActionTypes.LOOK; + } + + @Override + public void write(JsonObject out) { + out.addProperty("direction", direction); + } + + @Override + public ActionResult execute(ActionContext ctx) { + LocalPlayer player = ctx.client() != null ? ctx.client().player : null; + if (player == null) { + return ActionResult.proceed(); + } + switch (direction) { + case "north" -> player.setYRot(180.0F); + case "south" -> player.setYRot(0.0F); + case "east" -> player.setYRot(-90.0F); + case "west" -> player.setYRot(90.0F); + case "up" -> player.setXRot(-90.0F); + case "down" -> player.setXRot(90.0F); + case "left" -> player.setYRot(Mth.wrapDegrees(player.getYRot() - 90.0F)); + case "right" -> player.setYRot(Mth.wrapDegrees(player.getYRot() + 90.0F)); + default -> { + } + } + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java b/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java new file mode 100644 index 0000000..cfbd89d --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java @@ -0,0 +1,40 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.player.LocalPlayer; + +public record MoveAction(String direction, float blocks) implements Action { + + private static final float BLOCKS_PER_TICK_PER_SPEED = 2.158F; + + @Override + public ActionType type() { + return ActionTypes.MOVE; + } + + @Override + public void write(JsonObject out) { + out.addProperty("direction", direction); + out.addProperty("blocks", blocks); + } + + @Override + public ActionResult execute(ActionContext ctx) { + LocalPlayer player = ctx.client() != null ? ctx.client().player : null; + if (player == null || blocks <= 0.0F) { + return ActionResult.proceed(); + } + KeyMapping key = switch (direction) { + case "back" -> ctx.client().options.keyDown; + case "left" -> ctx.client().options.keyLeft; + case "right" -> ctx.client().options.keyRight; + default -> ctx.client().options.keyUp; + }; + float speed = player.getSpeed() > 0.0F ? player.getSpeed() : 0.1F; + int ticks = Math.max(1, Math.round(blocks / (speed * BLOCKS_PER_TICK_PER_SPEED))); + key.setDown(true); + ctx.scheduler().schedule(ticks, () -> key.setDown(false)); + return ActionResult.delay(ticks); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java new file mode 100644 index 0000000..eed9bbd --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java @@ -0,0 +1,26 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; + +import java.util.concurrent.ThreadLocalRandom; + +public record RandomWaitAction(int min, int max) implements Action { + + @Override + public ActionType type() { + return ActionTypes.RANDOM_WAIT; + } + + @Override + public void write(JsonObject out) { + out.addProperty("min", min); + out.addProperty("max", max); + } + + @Override + public ActionResult execute(ActionContext ctx) { + int low = Math.max(0, Math.min(min, max)); + int high = Math.max(0, Math.max(min, max)); + return ActionResult.delay(ThreadLocalRandom.current().nextInt(low, high + 1)); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java new file mode 100644 index 0000000..5dc38b2 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java @@ -0,0 +1,32 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.KeyMapping; + +public record SneakAction(String mode) implements Action { + + @Override + public ActionType type() { + return ActionTypes.SNEAK; + } + + @Override + public void write(JsonObject out) { + out.addProperty("mode", mode); + } + + @Override + public ActionResult execute(ActionContext ctx) { + if (ctx.client() == null || ctx.client().player == null) { + return ActionResult.proceed(); + } + KeyMapping shift = ctx.client().options.keyShift; + boolean target = switch (mode) { + case "on" -> true; + case "off" -> false; + default -> !shift.isDown(); + }; + shift.setDown(target); + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java new file mode 100644 index 0000000..e097674 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java @@ -0,0 +1,33 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.player.LocalPlayer; + +public record SprintAction(String mode) implements Action { + + @Override + public ActionType type() { + return ActionTypes.SPRINT; + } + + @Override + public void write(JsonObject out) { + out.addProperty("mode", mode); + } + + @Override + public ActionResult execute(ActionContext ctx) { + LocalPlayer player = ctx.client() != null ? ctx.client().player : null; + if (player == null) { + return ActionResult.proceed(); + } + boolean target = switch (mode) { + case "on" -> true; + case "off" -> false; + default -> !player.isSprinting(); + }; + ctx.client().options.keySprint.setDown(target); + player.setSprinting(target); + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java b/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java new file mode 100644 index 0000000..47c92ec --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java @@ -0,0 +1,27 @@ +package com.kingjayan.keybinds.core.action; + +import com.google.gson.JsonObject; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; + +public record TurnAction(float degrees) implements Action { + + @Override + public ActionType type() { + return ActionTypes.TURN; + } + + @Override + public void write(JsonObject out) { + out.addProperty("degrees", degrees); + } + + @Override + public ActionResult execute(ActionContext ctx) { + LocalPlayer player = ctx.client() != null ? ctx.client().player : null; + if (player != null) { + player.setYRot(Mth.wrapDegrees(degrees)); + } + return ActionResult.proceed(); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java b/src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java similarity index 98% rename from src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java rename to src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java index 1ffe141..0094369 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/KeyNames.java +++ b/src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java @@ -1,4 +1,4 @@ -package com.kingjayan.keybinds.dsl; +package com.kingjayan.keybinds.core.input; import org.lwjgl.glfw.GLFW; diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java new file mode 100644 index 0000000..9c06ef3 --- /dev/null +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java @@ -0,0 +1,101 @@ +package com.kingjayan.keybinds.core.trigger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.kingjayan.keybinds.core.input.KeyNames; +import org.lwjgl.glfw.GLFW; + +import java.util.List; +import java.util.function.IntPredicate; + +public final class KeyComboTrigger implements Trigger { + + private final List keys; + private final int[][] codes; + + public KeyComboTrigger(List keys) { + this.keys = List.copyOf(keys); + this.codes = resolveAll(this.keys); + } + + public List keys() { + return keys; + } + + public int[][] codes() { + return codes; + } + + public static int[][] resolveAll(List keys) { + int[][] codes = new int[keys.size()][]; + for (int i = 0; i < keys.size(); i++) { + codes[i] = resolve(keys.get(i)); + } + return codes; + } + + public static int[] resolve(String name) { + String lower = name.trim().toLowerCase(); + switch (lower) { + case "ctrl", "control" -> { + return new int[] {GLFW.GLFW_KEY_LEFT_CONTROL, GLFW.GLFW_KEY_RIGHT_CONTROL}; + } + case "shift" -> { + return new int[] {GLFW.GLFW_KEY_LEFT_SHIFT, GLFW.GLFW_KEY_RIGHT_SHIFT}; + } + case "alt" -> { + return new int[] {GLFW.GLFW_KEY_LEFT_ALT, GLFW.GLFW_KEY_RIGHT_ALT}; + } + default -> { + Integer code = KeyNames.keyCode(lower); + if (code != null) { + return new int[] {code}; + } + try { + return new int[] {Integer.parseInt(lower)}; + } catch (NumberFormatException e) { + return new int[0]; + } + } + } + } + + public static boolean allDown(int[][] codes, IntPredicate keyDown) { + if (codes.length == 0) { + return false; + } + for (int[] alternatives : codes) { + boolean down = false; + for (int code : alternatives) { + if (keyDown.test(code)) { + down = true; + break; + } + } + if (!down) { + return false; + } + } + return true; + } + + @Override + public TriggerType type() { + return TriggerTypes.KEY_COMBO; + } + + @Override + public void write(JsonObject out) { + JsonArray array = new JsonArray(); + for (String key : keys) { + array.add(key); + } + out.add("keys", array); + } + + @Override + public TriggerResult evaluate(TriggerContext ctx) { + boolean active = allDown(codes, ctx::keyDown); + return new TriggerResult(active, ctx.edge(this, active)); + } +} diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java index dc38703..f681da2 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java @@ -94,6 +94,13 @@ private void classify(Trigger trigger, Classification out) { out.buttons.add(mouse.button()); } } + case KeyComboTrigger combo -> { + for (int[] alternatives : combo.codes()) { + for (int code : alternatives) { + out.keys.add(code); + } + } + } case AllOfTrigger all -> { for (Trigger child : all.children()) { classify(child, out); diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java index f966489..45c55a4 100644 --- a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java +++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java @@ -34,7 +34,10 @@ public final class TriggerTypes { })); public static final TriggerType HOLDING = TriggerRegistry.register(new TriggerType("holding", - json -> new HoldingTrigger(readStrings(json)))); + json -> new HoldingTrigger(readStrings(json, "items")))); + + public static final TriggerType KEY_COMBO = TriggerRegistry.register(new TriggerType("key_combo", + json -> new KeyComboTrigger(readStrings(json, "keys")))); public static final TriggerType KEY_RELEASE = TriggerRegistry.register(new TriggerType("key_release", json -> new KeyReleaseTrigger(GsonHelper.getAsInt(json, "key", GLFW.GLFW_KEY_UNKNOWN)))); @@ -57,9 +60,9 @@ private TriggerTypes() { public static void bootstrap() { } - private static List readStrings(JsonObject json) { + private static List readStrings(JsonObject json, String field) { List values = new ArrayList<>(); - for (JsonElement element : GsonHelper.getAsJsonArray(json, "items", new JsonArray())) { + for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) { if (element.isJsonPrimitive()) { values.add(element.getAsString()); } diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java index 7937291..c2f4ae0 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java @@ -17,7 +17,7 @@ public final class EditorSpecs { public enum Kind { - INT, BOOL, STRING, STRING_LIST, ENUM, KEY, MOUSE + INT, FLOAT, BOOL, STRING, STRING_LIST, ENUM, KEY, MOUSE } public record ParamSpec(String key, String label, Kind kind, String def, List options) { @@ -65,6 +65,23 @@ public record TypeSpec(String id, String label, Nesting nesting, List ParamSpec.of("useDelay", "Use ticks", Kind.INT, "3"), ParamSpec.of("swapBackDelay", "Swap-back ticks", Kind.INT, "2")); action("shield_totem_swap", "Shield/totem offhand swap"); + action("look", "Look toward", + ParamSpec.choice("direction", "Direction", "north", + List.of("north", "south", "east", "west", "up", "down", "left", "right"))); + action("turn", "Turn to yaw", + ParamSpec.of("degrees", "Yaw degrees", Kind.FLOAT, "0")); + action("sprint", "Sprint", + ParamSpec.choice("mode", "Mode", "on", List.of("on", "off", "toggle"))); + action("sneak", "Sneak", + ParamSpec.choice("mode", "Mode", "on", List.of("on", "off", "toggle"))); + action("jump", "Jump"); + action("double_jump", "Double jump"); + action("move", "Move", + ParamSpec.choice("direction", "Direction", "forward", List.of("forward", "back", "left", "right")), + ParamSpec.of("blocks", "Blocks", Kind.FLOAT, "1")); + action("random_wait", "Random wait", + ParamSpec.of("min", "Min ticks", Kind.INT, "1"), + ParamSpec.of("max", "Max ticks", Kind.INT, "10")); trigger("key", "Key press", Nesting.NONE, List.of(ParamSpec.of("key", "Key", Kind.KEY, "-1"))); @@ -78,6 +95,8 @@ public record TypeSpec(String id, String label, Nesting nesting, List List.of(ParamSpec.choice("condition", "Condition", "low_health", PlayerConditions.ids()))); trigger("holding", "Holding item", Nesting.NONE, List.of(ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""))); + trigger("key_combo", "Key combo", Nesting.NONE, + List.of(ParamSpec.of("keys", "Keys (ctrl, c)", Kind.STRING_LIST, ""))); trigger("slot_selected", "Hotbar slot selected", Nesting.NONE, List.of(ParamSpec.of("slot", "Slot (0-8)", Kind.INT, "0"))); trigger("riding", "Riding a mount", Nesting.NONE, List.of()); @@ -121,6 +140,7 @@ public static JsonObject defaultJson(TypeSpec spec) { for (ParamSpec param : spec.params()) { switch (param.kind()) { case INT, KEY, MOUSE -> json.addProperty(param.key(), Integer.parseInt(param.def())); + case FLOAT -> json.addProperty(param.key(), Float.parseFloat(param.def())); case BOOL -> json.addProperty(param.key(), Boolean.parseBoolean(param.def())); case STRING, ENUM -> json.addProperty(param.key(), param.def()); case STRING_LIST -> json.add(param.key(), new JsonArray()); @@ -146,6 +166,16 @@ public static String describeAction(JsonObject json) { case "use" -> "Use held item"; case "use_item" -> "Use " + orAny(GsonHelper.getAsString(json, "item", "")); case "shield_totem_swap" -> "Shield/totem offhand swap"; + case "look" -> "Look " + GsonHelper.getAsString(json, "direction", "north"); + case "turn" -> "Turn to " + trimFloat(GsonHelper.getAsFloat(json, "degrees", 0.0F)) + "°"; + case "sprint" -> "Sprint " + GsonHelper.getAsString(json, "mode", "on"); + case "sneak" -> "Sneak " + GsonHelper.getAsString(json, "mode", "on"); + case "jump" -> "Jump"; + case "double_jump" -> "Double jump"; + case "move" -> "Move " + GsonHelper.getAsString(json, "direction", "forward") + " " + + trimFloat(GsonHelper.getAsFloat(json, "blocks", 1.0F)) + " blocks"; + case "random_wait" -> "Wait " + GsonHelper.getAsInt(json, "min", 1) + "-" + + GsonHelper.getAsInt(json, "max", 10) + " ticks"; default -> type; }; } @@ -159,6 +189,7 @@ public static String describeTrigger(JsonObject json) { case "mouse_release" -> "Mouse release: " + buttonName(GsonHelper.getAsInt(json, "button", -1)); case "condition" -> "Condition: " + GsonHelper.getAsString(json, "condition", ""); case "holding" -> "Holding " + orAny(joinList(json, "items")); + case "key_combo" -> "Combo: " + orAny(String.join(" + ", readStringList(json, "keys"))); case "slot_selected" -> "Slot " + (GsonHelper.getAsInt(json, "slot", 0) + 1) + " selected"; case "riding" -> "Riding a mount"; case "held_durability" -> "Held durability ≤ " + GsonHelper.getAsInt(json, "durability", 0); @@ -197,6 +228,10 @@ private static String itemsOrTag(JsonObject json) { return tag.isEmpty() ? "anything" : "#" + tag; } + public static String trimFloat(float value) { + return value == (int) value ? String.valueOf((int) value) : String.valueOf(value); + } + private static String orAny(String value) { return value.isEmpty() ? "anything" : value; } diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java index ed06686..3c291c7 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java @@ -81,6 +81,14 @@ private void addRow(EditorSpecs.ParamSpec spec, int y) { writers.add(() -> working.addProperty(spec.key(), parsable(box.getValue()) ? Integer.parseInt(box.getValue()) : Integer.parseInt(spec.def()))); } + case FLOAT -> { + EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label())); + box.setValue(EditorSpecs.trimFloat( + GsonHelper.getAsFloat(working, spec.key(), Float.parseFloat(spec.def())))); + add(box); + writers.add(() -> working.addProperty(spec.key(), + floatParsable(box.getValue()) ? Float.parseFloat(box.getValue()) : Float.parseFloat(spec.def()))); + } case STRING -> { EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label())); box.setMaxLength(256); @@ -152,6 +160,15 @@ private static boolean parsable(String text) { } } + private static boolean floatParsable(String text) { + try { + Float.parseFloat(text); + return true; + } catch (NumberFormatException e) { + return false; + } + } + @Override public boolean keyPressed(KeyEvent event) { if (capturingSpec != null && capturingSpec.kind() == EditorSpecs.Kind.KEY) { From 27dd65ab6e190199e91d06fd053d6d0ae706a8e8 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Mon, 13 Jul 2026 18:15:42 -0500 Subject: [PATCH 18/19] support combos, floats, and new actions in the script dsl --- .../com/kingjayan/keybinds/dsl/ScriptAst.java | 5 +- .../keybinds/dsl/ScriptCompiler.java | 17 ++++++ .../kingjayan/keybinds/dsl/ScriptDocs.java | 9 ++++ .../kingjayan/keybinds/dsl/ScriptLexer.java | 12 ++++- .../kingjayan/keybinds/dsl/ScriptParser.java | 5 ++ .../kingjayan/keybinds/dsl/ScriptPrinter.java | 6 +++ .../kingjayan/keybinds/dsl/ScriptProgram.java | 12 ++++- .../keybinds/ui/editor/CodeEditor.java | 1 + .../com/kingjayan/keybinds/ScriptDslTest.java | 52 +++++++++++++++++++ 9 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java index ea68166..a3f0dda 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java @@ -26,12 +26,15 @@ public record CallExpr(String name, List args, int line) implements Expr { public record Arg(String name, Value value) { } - public sealed interface Value permits IntValue, StrValue, BoolValue, ListValue { + public sealed interface Value permits IntValue, FloatValue, StrValue, BoolValue, ListValue { } public record IntValue(int value) implements Value { } + public record FloatValue(float value) implements Value { + } + public record StrValue(String value, boolean bare) implements Value { } diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java index 9477d12..0977e07 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java @@ -5,6 +5,7 @@ import com.kingjayan.keybinds.core.action.Action; import com.kingjayan.keybinds.core.action.ActionRegistry; import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.core.input.KeyNames; import com.kingjayan.keybinds.core.trigger.Trigger; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; import com.kingjayan.keybinds.dsl.ScriptAst.ActionStmt; @@ -13,6 +14,7 @@ import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue; import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr; import com.kingjayan.keybinds.dsl.ScriptAst.Expr; +import com.kingjayan.keybinds.dsl.ScriptAst.FloatValue; import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt; import com.kingjayan.keybinds.dsl.ScriptAst.IntValue; import com.kingjayan.keybinds.dsl.ScriptAst.ListValue; @@ -129,6 +131,7 @@ private static JsonObject triggerCallJson(CallExpr call) { private static String resolveTriggerName(String name) { String resolved = switch (name) { case "mouse" -> "mouse_button"; + case "combo" -> "key_combo"; case "all_of", "any_of", "not" -> null; default -> name; }; @@ -182,6 +185,8 @@ private static Condition leafCondition(CallExpr call) { case "condition" -> new ScriptProgram.PlayerCondition(json.get("condition").getAsString()); case "holding" -> new ScriptProgram.HoldingCondition( EditorSpecs.readStringList(json, "items")); + case "key_combo" -> new ScriptProgram.ComboCondition( + EditorSpecs.readStringList(json, "keys")); case "riding" -> new ScriptProgram.RidingCondition(); case "slot_selected" -> new ScriptProgram.SlotCondition(json.get("slot").getAsInt()); case "held_durability" -> new ScriptProgram.DurabilityCondition(json.get("durability").getAsInt()); @@ -232,6 +237,7 @@ private static String expected(List params) { private static void applyValue(JsonObject json, EditorSpecs.ParamSpec param, Value value, int line) { switch (param.kind()) { case INT -> json.addProperty(param.key(), intValue(param, value, line)); + case FLOAT -> json.addProperty(param.key(), floatValue(param, value, line)); case KEY -> json.addProperty(param.key(), keyValue(param, value, line, true)); case MOUSE -> json.addProperty(param.key(), keyValue(param, value, line, false)); case BOOL -> { @@ -254,6 +260,7 @@ private static void applyValue(JsonObject json, EditorSpecs.ParamSpec param, Val switch (value) { case ListValue list -> list.values().forEach(array::add); case StrValue str -> array.add(str.value()); + case IntValue intValue -> array.add(String.valueOf(intValue.value())); default -> throw new ScriptException(line, "'" + param.key() + "' expects a string or a [list]"); } @@ -268,6 +275,16 @@ private static int intValue(EditorSpecs.ParamSpec param, Value value, int line) throw new ScriptException(line, "'" + param.key() + "' expects a number"); } + private static float floatValue(EditorSpecs.ParamSpec param, Value value, int line) { + if (value instanceof FloatValue floatValue) { + return floatValue.value(); + } + if (value instanceof IntValue intValue) { + return intValue.value(); + } + throw new ScriptException(line, "'" + param.key() + "' expects a number"); + } + private static int keyValue(EditorSpecs.ParamSpec param, Value value, int line, boolean keyboard) { if (value instanceof IntValue intValue) { return intValue.value(); diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java index 6f8c8ad..06ec2c8 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java @@ -38,6 +38,7 @@ static Entry text(String description) { trigger("key_release", "key_release(r)", "Fires when the key is released."); trigger("mouse", "mouse(left)", "Fires when the mouse button is pressed. Inside if: true while held."); trigger("mouse_release", "mouse_release(left)", "Fires when the mouse button is released."); + trigger("combo", "combo(ctrl, c)", "Fires when all listed keys are held together; ctrl, shift, and alt match either side. Inside if: true while all held."); trigger("holding", "holding(\"minecraft:mace\")", "Fires when you start holding one of the items; empty matches anything."); trigger("slot_selected", "slot_selected(0)", "Fires when the hotbar slot (0-8) is selected."); trigger("riding", "riding", "Fires when you mount a vehicle or mob."); @@ -56,6 +57,14 @@ static Entry text(String description) { action("use", "use", "Uses the held item once."); action("use_item", "use_item(item=\"minecraft:ender_pearl\", hotbarGate=any, vanillaSwap=false, swapToDelay=2, useDelay=3, swapBackDelay=2)", "Swaps to an item, uses it, and swaps back with configurable tick delays."); action("shield_totem_swap", "shield_totem_swap", "Swaps shield and totem in the offhand."); + action("look", "look(north)", "Snaps the camera toward north, south, east, west, up, down, or 90° left/right."); + action("turn", "turn(90)", "Sets the camera yaw to the given degrees (0 south, 90 west, 180 north, -90 east)."); + action("sprint", "sprint(on)", "Turns sprinting on, off, or toggle."); + action("sneak", "sneak(toggle)", "Turns sneaking on, off, or toggle."); + action("jump", "jump", "Taps the jump key once."); + action("double_jump", "double_jump", "Taps jump twice in quick succession."); + action("move", "move(forward, 1.2)", "Holds a movement key (forward, back, left, right) long enough to cover roughly that many blocks."); + action("random_wait", "random_wait(2, 10)", "Pauses the macro for a random number of ticks between min and max."); ENTRIES.add(Entry.header("Player conditions")); for (String id : PlayerConditions.ids()) { diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java index 1bb6db6..d003687 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java @@ -6,7 +6,7 @@ public final class ScriptLexer { public enum TokenType { - IDENT, INT, STRING, LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, COMMA, EQUALS, EOF + IDENT, INT, FLOAT, STRING, LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, COMMA, EQUALS, EOF } public record Token(TokenType type, String text, int line) { @@ -82,7 +82,15 @@ public static List tokenize(String source) { while (i < length && Character.isDigit(source.charAt(i))) { i++; } - tokens.add(new Token(TokenType.INT, source.substring(start, i), line)); + boolean decimal = false; + if (i + 1 < length && source.charAt(i) == '.' && Character.isDigit(source.charAt(i + 1))) { + decimal = true; + i++; + while (i < length && Character.isDigit(source.charAt(i))) { + i++; + } + } + tokens.add(new Token(decimal ? TokenType.FLOAT : TokenType.INT, source.substring(start, i), line)); } else if (isWordChar(c)) { int start = i; while (i < length && isWordChar(source.charAt(i))) { diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java index d3a8209..c6b1298 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java @@ -6,6 +6,7 @@ import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue; import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr; import com.kingjayan.keybinds.dsl.ScriptAst.Expr; +import com.kingjayan.keybinds.dsl.ScriptAst.FloatValue; import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt; import com.kingjayan.keybinds.dsl.ScriptAst.IntValue; import com.kingjayan.keybinds.dsl.ScriptAst.ListValue; @@ -169,6 +170,10 @@ private Value parseValue() { advance(); return new IntValue(Integer.parseInt(token.text())); } + case FLOAT -> { + advance(); + return new FloatValue(Float.parseFloat(token.text())); + } case STRING -> { advance(); return new StrValue(token.text(), false); diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java index 3277b8d..e1936e7 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.kingjayan.keybinds.core.action.Action; import com.kingjayan.keybinds.core.action.ActionRegistry; +import com.kingjayan.keybinds.core.input.KeyNames; import com.kingjayan.keybinds.core.macro.Macro; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; import com.kingjayan.keybinds.ui.editor.EditorSpecs; @@ -52,6 +53,7 @@ private static String expr(JsonObject json, int depth) { case "mouse_release" -> "mouse_release(" + keyArg(json, "button", false) + ")"; case "condition" -> GsonHelper.getAsString(json, "condition", "low_health"); case "holding" -> "holding(" + quotedList(json, "items") + ")"; + case "key_combo" -> "combo(" + String.join(", ", EditorSpecs.readStringList(json, "keys")) + ")"; case "slot_selected" -> "slot_selected(" + GsonHelper.getAsInt(json, "slot", 0) + ")"; case "riding" -> "riding"; case "held_durability" -> "held_durability(" + GsonHelper.getAsInt(json, "durability", 10) + ")"; @@ -130,6 +132,10 @@ private static String paramValue(JsonObject json, EditorSpecs.ParamSpec param) { ? KeyNames.keyName(value) : KeyNames.buttonName(value); return name != null ? name : String.valueOf(value); } + case FLOAT -> { + float value = GsonHelper.getAsFloat(json, param.key(), Float.parseFloat(param.def())); + return value == Float.parseFloat(param.def()) ? null : EditorSpecs.trimFloat(value); + } case BOOL -> { boolean value = GsonHelper.getAsBoolean(json, param.key(), Boolean.parseBoolean(param.def())); return value == Boolean.parseBoolean(param.def()) ? null : String.valueOf(value); diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java index 486fe5e..1efb8a2 100644 --- a/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java +++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java @@ -2,6 +2,7 @@ import com.kingjayan.keybinds.core.action.Action; import com.kingjayan.keybinds.core.condition.PlayerConditions; +import com.kingjayan.keybinds.core.trigger.KeyComboTrigger; import com.kingjayan.keybinds.core.trigger.Trigger; import com.kingjayan.keybinds.game.ItemLookup; import com.mojang.blaze3d.platform.InputConstants; @@ -45,7 +46,7 @@ public static Instruction jumpIfFalse(Condition condition) { public sealed interface Condition permits AllCondition, AnyCondition, NotCondition, KeyDownCondition, MouseDownCondition, PlayerCondition, HoldingCondition, RidingCondition, SlotCondition, - DurabilityCondition { + DurabilityCondition, ComboCondition { boolean test(Minecraft client); } @@ -101,6 +102,15 @@ public boolean test(Minecraft client) { } } + public record ComboCondition(List keys) implements Condition { + + @Override + public boolean test(Minecraft client) { + return client != null && KeyComboTrigger.allDown(KeyComboTrigger.resolveAll(keys), + key -> InputConstants.isKeyDown(client.getWindow(), key)); + } + } + public record PlayerCondition(String id) implements Condition { @Override diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java index e22b5e8..7cb77c6 100644 --- a/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java +++ b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java @@ -54,6 +54,7 @@ public class CodeEditor extends AbstractWidget { TRIGGER_WORDS.add(spec.id()); } TRIGGER_WORDS.add("mouse"); + TRIGGER_WORDS.add("combo"); TRIGGER_WORDS.addAll(PlayerConditions.ids()); for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) { ACTION_WORDS.add(spec.id()); diff --git a/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java index 78edfb3..78117f8 100644 --- a/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java +++ b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java @@ -4,8 +4,10 @@ import com.kingjayan.keybinds.core.action.ActionContext; import com.kingjayan.keybinds.core.action.ActionRegistry; import com.kingjayan.keybinds.core.action.ActionTypes; +import com.kingjayan.keybinds.core.action.RandomWaitAction; import com.kingjayan.keybinds.core.macro.Macro; import com.kingjayan.keybinds.core.schedule.TickScheduler; +import com.kingjayan.keybinds.core.trigger.KeyComboTrigger; import com.kingjayan.keybinds.core.trigger.TriggerRegistry; import com.kingjayan.keybinds.core.trigger.TriggerTypes; import com.kingjayan.keybinds.dsl.ScriptCompiler; @@ -19,7 +21,9 @@ import org.junit.jupiter.api.Test; import org.lwjgl.glfw.GLFW; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -138,6 +142,54 @@ void scriptFieldRoundTripsThroughCodec() { assertEquals(written, MacroCodec.writeMacro(reloaded)); } + @Test + void comboTriggerCompilesAndMatches() { + ScriptProgram program = ScriptCompiler.compile(""" + when combo(ctrl, c) + attack + """); + KeyComboTrigger combo = (KeyComboTrigger) program.trigger(); + assertEquals(List.of("ctrl", "c"), combo.keys()); + Set pressed = new HashSet<>(Set.of(GLFW.GLFW_KEY_RIGHT_CONTROL, GLFW.GLFW_KEY_C)); + assertTrue(KeyComboTrigger.allDown(combo.codes(), pressed::contains)); + pressed.remove(GLFW.GLFW_KEY_C); + assertFalse(KeyComboTrigger.allDown(combo.codes(), pressed::contains)); + assertFalse(KeyComboTrigger.allDown(KeyComboTrigger.resolveAll(List.of()), key -> true)); + JsonObject json = TriggerRegistry.toJson(program.trigger()); + assertEquals(json, TriggerRegistry.toJson(TriggerRegistry.fromJson(json))); + } + + @Test + void newActionsAndFloatsCompile() { + ScriptProgram program = ScriptCompiler.compile(""" + when combo(d, r) + sprint(on) + wait(2) + move(forward, 1.2) + turn(90) + look(north) + double_jump + sneak(toggle) + random_wait(2, 5) + """); + assertEquals(8, program.instructions().size()); + JsonObject move = ActionRegistry.toJson(program.instructions().get(2).action); + assertEquals("forward", move.get("direction").getAsString()); + assertEquals(1.2F, move.get("blocks").getAsFloat()); + assertEquals(90.0F, + ActionRegistry.toJson(program.instructions().get(3).action).get("degrees").getAsFloat()); + } + + @Test + void randomWaitDelaysWithinBounds() { + RandomWaitAction action = new RandomWaitAction(2, 5); + ActionContext ctx = new ActionContext(null, new TickScheduler(), -1); + for (int i = 0; i < 50; i++) { + int delay = action.execute(ctx).delayTicks(); + assertTrue(delay >= 2 && delay <= 5); + } + } + @Test void reportsErrorsWithLineNumbers() { assertEquals(1, assertThrows(ScriptException.class, From 0343431cf9b135596045e94dc80e9f97bc164359 Mon Sep 17 00:00:00 2001 From: KingJayan Date: Mon, 13 Jul 2026 18:16:27 -0500 Subject: [PATCH 19/19] bump version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 91e8760..929969a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loader_version=0.19.3 loom_version=1.17-SNAPSHOT # Mod Properties -mod_version=2.0.0-beta.1 +mod_version=2.1.0-beta.0 maven_group=com.kingjayan # Dependencies