diff --git a/.gitignore b/.gitignore index 951fec60..d0354496 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ eclipse /neoforge/runs/ /neoforge/run/ /forge/ +neoforge/logs/latest.log +common/src/main/java/com/mrcrayfish/controllable/mixin/client/MinecraftMixin.java diff --git a/build.gradle b/build.gradle index 21064435..54b105e5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,13 @@ plugins { id 'fabric-loom' version '1.7-SNAPSHOT' apply false id 'net.neoforged.moddev' version '2.0.73' apply false +} +repositories { + maven { url 'https://maven.mrcrayfish.com/' } + mavenCentral() + maven { url 'https://maven.blamejared.com' } + maven { url 'https://maven.terraformersmc.com/' } + maven { url 'https://maven.shedaniel.me' } + maven { url 'https://libraries.minecraft.net' } + maven { url 'https://maven.neoforged.net/releases' } } \ No newline at end of file diff --git a/common/build.gradle b/common/build.gradle index 7f2ccb11..110d9bf8 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -16,11 +16,12 @@ neoForge { } dependencies { - compileOnly "com.mrcrayfish:framework-common:${minecraft_version}-${framework_version}" + compileOnly files('libs/framework-common-1.21.1-0.13.11.jar') compileOnly "mezz.jei:jei-${minecraft_version}-common:${jei_version}" compileOnly "mezz.jei:jei-${minecraft_version}-gui:${jei_version}" compileOnly "mezz.jei:jei-${minecraft_version}-lib:${jei_version}" - compileOnly "com.mrcrayfish:controllable-sdl:${controllable_sdl_version}" + compileOnly files('libs/controllable-sdl-2.32.10-1.1.0.jar') + compileOnly files('libs/ShoulderSurfing-NeoForge-1.21.1-4.16.1.jar') compileOnly "dev.emi:emi-xplat-mojmap:${emi_version}" compileOnly "me.shedaniel:RoughlyEnoughItems-neoforge:${rei_version}" compileOnly group: 'org.spongepowered', name: 'mixin', version: '0.8.7' diff --git a/common/libs/controllable-sdl-2.32.10-1.1.0.jar b/common/libs/controllable-sdl-2.32.10-1.1.0.jar new file mode 100644 index 00000000..8344a033 Binary files /dev/null and b/common/libs/controllable-sdl-2.32.10-1.1.0.jar differ diff --git a/common/libs/framework-common-1.21.1-0.13.11.jar b/common/libs/framework-common-1.21.1-0.13.11.jar new file mode 100644 index 00000000..e7e89909 Binary files /dev/null and b/common/libs/framework-common-1.21.1-0.13.11.jar differ diff --git a/common/src/main/java/com/mrcrayfish/controllable/Controllable.java b/common/src/main/java/com/mrcrayfish/controllable/Controllable.java index a635bb49..0bacffa9 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/Controllable.java +++ b/common/src/main/java/com/mrcrayfish/controllable/Controllable.java @@ -34,6 +34,7 @@ public class Controllable private static final boolean EMI_LOADED = Utils.isModLoaded("emi"); private static final boolean REI_LOADED = Utils.isModLoaded("roughlyenoughitems"); private static final boolean JEI_LOADED = Utils.isModLoaded("jei") && !EMI_LOADED && !REI_LOADED; + private static final boolean TACZ_LOADED = Utils.isModLoaded("tacz"); public static void init() { @@ -44,6 +45,13 @@ public static void init() CAMERA_HANDLER.registerEvents(); RADIAL_MENU.registerEvents(); SCROLLING_HANDLER.registerEvents(); + + // DEBUG: Print all registered bindings + System.out.println("[Controllable] === Registered Bindings ==="); + BINDING_REGISTRY.getBindings().forEach(binding -> { + System.out.println("[Controllable] " + binding.getDescription() + " -> Button: " + binding.getButton() + " (Multi: " + binding.isMultiButton() + ")"); + }); + System.out.println("[Controllable] === End Bindings ==="); } public static BindingRegistry getBindingRegistry() @@ -76,6 +84,11 @@ public static RumbleHandler getRumbleHandler() return RUMBLE_HANDLER; } + public static boolean isTaczLoaded() + { + return TACZ_LOADED; + } + public static boolean isArchitecturyLoaded() { return ARCHITECTURY_LOADED; diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/ControllerEvents.java b/common/src/main/java/com/mrcrayfish/controllable/client/ControllerEvents.java index f12714b8..9d3f250f 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/ControllerEvents.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/ControllerEvents.java @@ -33,6 +33,9 @@ public static void init() private static void onScreenInit(Screen screen) { ButtonBinding.resetButtonStates(); + // Clear all active tick/render/movement handlers and combo suppression state so that + // held-button actions (e.g. jump from a combo) don't persist after a screen opens. + Controllable.getInputHandler().clearActiveHandlers(); // Fixes an issue where using item is not stopped after opening a screen if(!released) diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/InputHandler.java b/common/src/main/java/com/mrcrayfish/controllable/client/InputHandler.java index 90bdc6ea..43545960 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/InputHandler.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/InputHandler.java @@ -24,6 +24,7 @@ import com.mrcrayfish.controllable.client.gui.navigation.SlotNavigationPoint; import com.mrcrayfish.controllable.client.gui.navigation.WidgetNavigationPoint; import com.mrcrayfish.controllable.client.input.Controller; +import com.mrcrayfish.controllable.client.input.ButtonStates; import com.mrcrayfish.controllable.client.settings.AnalogMovement; import com.mrcrayfish.controllable.client.settings.Thumbstick; import com.mrcrayfish.controllable.client.util.ClientHelper; @@ -82,6 +83,7 @@ import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -101,6 +103,20 @@ public class InputHandler private @Nullable ButtonBinding activeVirtualBinding; private boolean initialized; + /** + * Buttons that appear as non-sole members in at least one multi-button combo. + * These are "pure modifier" buttons — pressing them must never immediately fire their + * single-button binding because doing so would conflict with any combo using them. + * Rebuilt every time the binding registry cache is rebuilt via rebuildComboModifiers(). + */ + private final Set comboModifierButtons = new HashSet<>(); + + /** + * Tracks which buttons were consumed as part of a successfully fired multi-button combo so + * that on release we do NOT fire their single-button bindings. + */ + private final Set comboSuppressedButtons = new HashSet<>(); + @ApiStatus.Internal public InputHandler() { @@ -128,48 +144,154 @@ public ButtonBinding getActiveVirtualBinding() return this.activeVirtualBinding; } + /** + * Called by InputProcessor with the full set of buttons newly pressed in a single frame. + * Handling all pressed buttons together allows us to detect combos whose buttons were all + * pressed within the same poll frame (e.g. LB+RB+Y captured together). + */ @ApiStatus.Internal - public void handleButtonInput(Controller controller, int button, boolean state) + public void handleButtonsPressed(Controller controller, List pressedButtons) { - if(controller == null) + if(controller == null || pressedButtons.isEmpty()) return; controller.updateInputTime(); - if(state) + // Pass 1: find every multi-button combo fully satisfied in this frame. + // A combo is satisfied if ALL its buttons are currently tracked as held, + // and at least one of its buttons was newly pressed this frame. + ButtonStates tracked = controller.getTrackedButtonStates(); + List completeCombos = new ArrayList<>(); + Set seen = new HashSet<>(); + + for(int button : pressedButtons) { for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(button)) { + if(!binding.isMultiButton() || !seen.add(binding)) + continue; + + boolean hasNewButton = false; + boolean allHeld = true; + for(int required : binding.getButtons()) + { + if(!tracked.getState(required)) { allHeld = false; break; } + if(pressedButtons.contains(required)) hasNewButton = true; + } + if(allHeld && hasNewButton) + completeCombos.add(binding); + } + } + + // Pass 2: among complete combos, only fire the longest ones. + // Shorter combos that are strict subsets of a longer one are suppressed. + if(!completeCombos.isEmpty()) + { + int maxLen = completeCombos.stream().mapToInt(ButtonBinding::getButtonCount).max().orElse(0); + for(ButtonBinding binding : completeCombos) + { + if(binding.getButtonCount() < maxLen) + continue; if(this.handleBindingPressed(controller, binding, false)) - break; + { + for(int comboBtn : binding.getButtons()) + this.comboSuppressedButtons.add(comboBtn); + } } } - else + + // Pass 3: fire single-button bindings for buttons not consumed by any combo. + for(int button : pressedButtons) { + if(this.comboSuppressedButtons.contains(button)) + continue; + if(this.comboModifierButtons.contains(button)) + continue; + for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(button)) { - ButtonHandler handler = binding.getHandler(); - if(!(handler instanceof BindingPressed)) + if(binding.isMultiButton()) continue; + if(this.handleBindingPressed(controller, binding, false)) + break; + } + } + } - if(!binding.isButtonDown()) - continue; + /** Called for individual button releases — releases are always single-button events. */ + @ApiStatus.Internal + public void handleButtonInput(Controller controller, int button, boolean state) + { + if(controller == null || state) + return; - ButtonBinding.setButtonState(binding, false); + controller.updateInputTime(); + this.handleButtonReleased(controller, button); + } + /** + * Called when a physical button is released. + * + * 1. Release any active multi-button combos that include this button (combo is broken). + * 2. Clear combo-suppression for this button. + * 3. If the button is a known modifier but was NOT combo-suppressed, fire its single-button + * binding now as an instant tap (the user pressed and released a modifier alone). + * 4. Release any active single-button binding normally. + */ + private void handleButtonReleased(Controller controller, int button) + { + Minecraft mc = Minecraft.getInstance(); - if(!(handler instanceof BindingReleased released)) - continue; + // --- Release active multi-button combos whose combination is now broken --- + for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(button)) + { + if(!binding.isMultiButton()) + continue; - // Cancel the handler if context is no longer valid - if(!binding.getContext().isActive()) - continue; + ButtonHandler handler = binding.getHandler(); + if(!(handler instanceof BindingPressed)) + continue; + + if(!binding.isButtonDown()) + continue; - Minecraft mc = Minecraft.getInstance(); + ButtonBinding.setButtonState(binding, false); + + if(handler instanceof BindingReleased released && binding.getContext().isActive()) + { Context context = new Context(binding, controller, mc, mc.player, mc.level, mc.screen, false); released.handleReleased(context); - return; } } + + // --- Clear combo suppression for this button --- + boolean wasComboSuppressed = this.comboSuppressedButtons.remove(button); + + // --- Release normal single-button bindings --- + // Skip if this button was part of a completed combo this session. + if(wasComboSuppressed) + return; + + for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(button)) + { + if(binding.isMultiButton()) + continue; + + ButtonHandler handler = binding.getHandler(); + if(!(handler instanceof BindingPressed)) + continue; + + if(!binding.isButtonDown()) + continue; + + ButtonBinding.setButtonState(binding, false); + + if(handler instanceof BindingReleased released && binding.getContext().isActive()) + { + Context context = new Context(binding, controller, mc, mc.player, mc.level, mc.screen, false); + released.handleReleased(context); + } + return; + } } @ApiStatus.Internal @@ -769,11 +891,51 @@ public static void craftRecipeBookItem() } } + /** + * Rebuilds the set of pure combo modifier buttons from the current binding registry. + * A button is a "pure modifier" ONLY if it appears in at least one multi-button combo AND + * has NO single-button binding of its own. Buttons that have both a single binding and + * appear in combos are NOT pure modifiers — they fire their single binding normally and + * only get suppressed when a combo actually completes using them. + * Called by BindingRegistry.rebuildCache(). + */ + @ApiStatus.Internal + public void rebuildComboModifiers() + { + this.comboModifierButtons.clear(); + + java.util.List allBindings = Controllable.getBindingRegistry().getRegisteredBindings(); + + // Collect all buttons that have at least one single-button binding + Set hasSingleBinding = new HashSet<>(); + for(ButtonBinding binding : allBindings) + { + if(!binding.isMultiButton() && !binding.isUnbound()) + { + hasSingleBinding.add(binding.getButton()); + } + } + + // A button is a pure modifier only if it appears in a combo but has NO single binding + for(ButtonBinding binding : allBindings) + { + if(binding.isMultiButton()) + { + for(int btn : binding.getButtons()) + { + if(!hasSingleBinding.contains(btn)) + this.comboModifierButtons.add(btn); + } + } + } + } + public void clearActiveHandlers() { this.activeTickHandlers.clear(); this.activeRenderHandlers.clear(); this.activeMovementInputHandlers.clear(); + this.comboSuppressedButtons.clear(); } public enum Navigate diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/InputProcessor.java b/common/src/main/java/com/mrcrayfish/controllable/client/InputProcessor.java index bb2e278f..fc015aa5 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/InputProcessor.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/InputProcessor.java @@ -15,6 +15,8 @@ import org.jetbrains.annotations.ApiStatus; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; import java.util.Queue; /** @@ -74,43 +76,73 @@ private void processButtonStates() while(!this.inputQueue.isEmpty()) { ButtonStates states = this.inputQueue.poll(); - for(int i = 0; i < Buttons.BUTTONS.length; i++) - { - this.processButton(Buttons.BUTTONS[i], states); - } + this.processButtonFrame(states); } } - private void processButton(int index, ButtonStates newStates) + /** + * Processes a full captured frame of button states. + * + * All newly-pressed buttons in the same frame are collected first, then passed together + * to the InputHandler so it can resolve combos across the whole batch before deciding + * which single-button bindings to fire. This avoids the ordering problem where Y (index 3) + * would be processed before LB (index 9) and RB (index 10) in the same frame, preventing + * LB+RB+Y from being recognised as a complete combo. + */ + private void processButtonFrame(ButtonStates newStates) { - boolean state = newStates.getState(index); - Screen screen = Minecraft.getInstance().screen; - if(screen instanceof ControllerLayoutScreen) - { - ((ControllerLayoutScreen) screen).processButton(index, newStates); - return; - } - Controller controller = Controllable.getController(); if(controller == null) return; ButtonStates trackedStates = controller.getTrackedButtonStates(); - if(state) + + // Layout screen handles raw events itself + if(screen instanceof ControllerLayoutScreen layoutScreen) { - if(!trackedStates.getState(index)) + for(int i = 0; i < Buttons.BUTTONS.length; i++) + layoutScreen.processButton(Buttons.BUTTONS[i], newStates); + return; + } + + // Collect newly-pressed and newly-released buttons for this frame + List newlyPressed = new ArrayList<>(); + List newlyReleased = new ArrayList<>(); + + for(int i = 0; i < Buttons.BUTTONS.length; i++) + { + int index = Buttons.BUTTONS[i]; + boolean state = newStates.getState(index); + boolean tracked = trackedStates.getState(index); + if(state && !tracked) + newlyPressed.add(index); + else if(!state && tracked) + newlyReleased.add(index); + } + + // Update tracked state for pressed buttons BEFORE dispatching any press events, + // so the InputHandler can see the full set of currently-held buttons when it checks + // whether a combo is complete. + for(int index : newlyPressed) + { + trackedStates.setState(index, true); + if(screen instanceof SettingsScreen settings && settings.isWaitingForButtonInput() && settings.processButton(index)) { - trackedStates.setState(index, true); - if(screen instanceof SettingsScreen settings && settings.isWaitingForButtonInput() && settings.processButton(index)) - return; - Controllable.getInputHandler().handleButtonInput(controller, index, true); // Handle on down + newlyPressed.remove((Integer) index); + break; // SettingsScreen consumes the first button it sees } } - else if(trackedStates.getState(index)) + + // Dispatch all press events together so InputHandler can batch-resolve combos + if(!newlyPressed.isEmpty()) + Controllable.getInputHandler().handleButtonsPressed(controller, newlyPressed); + + // Dispatch release events (order doesn't matter for releases) + for(int index : newlyReleased) { trackedStates.setState(index, false); - Controllable.getInputHandler().handleButtonInput(controller, index, false); // Handle on release + Controllable.getInputHandler().handleButtonInput(controller, index, false); } } diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/binding/BindingRegistry.java b/common/src/main/java/com/mrcrayfish/controllable/client/binding/BindingRegistry.java index 5382c22f..2ce552d5 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/binding/BindingRegistry.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/binding/BindingRegistry.java @@ -8,6 +8,8 @@ import com.google.common.io.MoreFiles; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.mrcrayfish.controllable.Constants; @@ -30,6 +32,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; import java.util.stream.Stream; /** @@ -44,7 +48,10 @@ public class BindingRegistry private final Map registeredBindings = new HashMap<>(); private final Map keyAdapters = new HashMap<>(); private final Multimap idToButtonList = TreeMultimap.create(Ordering.natural(), - Comparator.comparing(binding -> binding.getContext().priority()).reversed().thenComparing(ButtonBinding::compareTo) + Comparator.comparing(binding -> binding.getContext().priority()).reversed() + .thenComparing((a, b) -> Boolean.compare(b.isMultiButton(), a.isMultiButton())) // multi-button first + .thenComparing(Comparator.comparing(ButtonBinding::getButtonCount).reversed()) // more buttons = higher priority + .thenComparing(ButtonBinding::compareTo) ); public BindingRegistry() @@ -159,7 +166,11 @@ public void register(ButtonBinding binding) this.bindings.add(binding); if(!binding.isUnbound()) { - this.idToButtonList.put(binding.getButton(), binding); + // For multi-button bindings, register all buttons + for(int button : binding.getButtons()) + { + this.idToButtonList.put(button, binding); + } } } } @@ -171,7 +182,11 @@ public void addKeyAdapter(KeyAdapterBinding binding) this.bindings.add(binding); if(!binding.isUnbound()) { - this.idToButtonList.put(binding.getButton(), binding); + // For multi-button bindings, register all buttons + for(int button : binding.getButtons()) + { + this.idToButtonList.put(button, binding); + } } this.save(); } @@ -182,7 +197,10 @@ public void removeKeyAdapter(KeyAdapterBinding binding) if(this.bindings.remove(binding)) { this.keyAdapters.remove(binding.getDescription()); - this.idToButtonList.remove(binding.getButton(), binding); + for(int button : binding.getButtons()) + { + this.idToButtonList.remove(button, binding); + } this.save(); } } @@ -192,8 +210,13 @@ public void rebuildCache() Controllable.getInputHandler().clearActiveHandlers(); this.idToButtonList.clear(); this.bindings.stream().filter(binding -> !binding.isUnbound()).forEach(binding -> { - this.idToButtonList.put(binding.getButton(), binding); + // For multi-button bindings, add to cache for each button + for(int button : binding.getButtons()) + { + this.idToButtonList.put(button, binding); + } }); + Controllable.getInputHandler().rebuildComboModifiers(); } public void completeSetup() @@ -211,10 +234,33 @@ public void completeSetup() this.registeredBindings.values().stream().filter(ButtonBinding::isNotReserved).forEach(binding -> { String description = binding.getDescription(); - if(adapters.get(description) instanceof JsonPrimitive value && value.isString()) + JsonElement element = adapters.get(description); + + if(element instanceof JsonPrimitive value && value.isString()) { + // Single button binding (backward compatible) ButtonBinding.setButton(binding, Buttons.getButtonFromName(value.getAsString())); } + else if(element instanceof JsonArray array) + { + // Multi-button binding + Set buttons = new TreeSet<>(); + for(JsonElement buttonElement : array) + { + if(buttonElement instanceof JsonPrimitive buttonValue && buttonValue.isString()) + { + int button = Buttons.getButtonFromName(buttonValue.getAsString()); + if(button >= 0) + { + buttons.add(button); + } + } + } + if(!buttons.isEmpty()) + { + ButtonBinding.setButtons(binding, buttons); + } + } }); } } @@ -243,16 +289,39 @@ public void completeSetup() } JsonObject adapters = GSON.fromJson(reader, JsonObject.class); adapters.asMap().forEach((key, element) -> { - if(!(element instanceof JsonPrimitive value) || !value.isString()) - return; KeyMapping mapping = bindings.get(key); - if(mapping != null) { - int button = Buttons.getButtonFromName(StringUtils.defaultIfEmpty(element.getAsString(), "")); - KeyAdapterBinding keyAdapter = new KeyAdapterBinding(button, mapping); - if(this.keyAdapters.putIfAbsent(keyAdapter.getDescription(), keyAdapter) == null) { - this.bindings.add(keyAdapter); - if(!keyAdapter.isUnbound()) { - this.idToButtonList.put(keyAdapter.getButton(), keyAdapter); + if (mapping != null) { + Set buttons = new TreeSet<>(); + if (element instanceof JsonArray array) { + for (JsonElement buttonElem : array) { + if (buttonElem instanceof JsonPrimitive value && value.isString()) { + int button = Buttons.getButtonFromName(value.getAsString()); + if (button >= 0) { + buttons.add(button); + } + } + } + } else if (element instanceof JsonPrimitive value && value.isString()) { + int button = Buttons.getButtonFromName(value.getAsString()); + if (button >= 0) { + buttons.add(button); + } + } + if (!buttons.isEmpty()) { + KeyAdapterBinding keyAdapter = new KeyAdapterBinding(buttons, mapping); + if (this.keyAdapters.putIfAbsent(keyAdapter.getDescription(), keyAdapter) == null) { + this.bindings.add(keyAdapter); + if (!keyAdapter.isUnbound()) { + for (int button : keyAdapter.getButtons()) { + this.idToButtonList.put(button, keyAdapter); + } + } + } + } else { + // Still add key mapping with no buttons (unbound) + KeyAdapterBinding keyAdapter = new KeyAdapterBinding(-1, mapping); + if (this.keyAdapters.putIfAbsent(keyAdapter.getDescription(), keyAdapter) == null) { + this.bindings.add(keyAdapter); } } } @@ -281,8 +350,26 @@ public void save() .filter(ButtonBinding::isNotReserved) .sorted(Comparator.comparing(ButtonBinding::getDescription)) .forEach(binding -> { - String name = StringUtils.defaultIfEmpty(Buttons.getNameForButton(binding.getButton()), ""); - bindings.addProperty(binding.getDescription(), name); + if(binding.isMultiButton()) + { + // Save as array for multi-button bindings + JsonArray array = new JsonArray(); + for(int button : binding.getButtons()) + { + String name = Buttons.getNameForButton(button); + if(name != null) + { + array.add(name); + } + } + bindings.add(binding.getDescription(), array); + } + else + { + // Save as string for single button bindings (backward compatible) + String name = StringUtils.defaultIfEmpty(Buttons.getNameForButton(binding.getButton()), ""); + bindings.addProperty(binding.getDescription(), name); + } }); String json = GSON.toJson(bindings); Path path = Utils.getConfigDirectory().resolve(Constants.MOD_ID).resolve("bindings.json"); @@ -298,12 +385,24 @@ public void save() { JsonObject adapters = new JsonObject(); this.keyAdapters.values().stream() - .filter(ButtonBinding::isNotReserved) - .sorted(Comparator.comparing(ButtonBinding::getDescription)) - .forEach(binding -> { + .filter(ButtonBinding::isNotReserved) + .sorted(Comparator.comparing(ButtonBinding::getDescription)) + .forEach(binding -> { + Set buttons = binding.getButtons(); + if (buttons.size() > 1) { + JsonArray array = new JsonArray(); + for (int button : buttons) { + String name = Buttons.getNameForButton(button); + if (name != null) { + array.add(name); + } + } + adapters.add(binding.getKeyMapping().getName(), array); + } else { String name = StringUtils.defaultIfEmpty(Buttons.getNameForButton(binding.getButton()), ""); adapters.addProperty(binding.getKeyMapping().getName(), name); - }); + } + }); String json = GSON.toJson(adapters); Path path = Utils.getConfigDirectory().resolve(Constants.MOD_ID).resolve("key_adapters.json"); MoreFiles.createParentDirectories(path); diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBinding.java b/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBinding.java index 587e2a0a..5bf00cb0 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBinding.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBinding.java @@ -6,19 +6,33 @@ import net.minecraft.client.resources.language.I18n; import org.jetbrains.annotations.ApiStatus; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + /** * Author: MrCrayfish */ public class ButtonBinding implements Comparable { private final int defaultButton; + private final Set defaultButtons; // For multi-button bindings private final String descriptionKey; private final String category; private final BindingContext context; private final boolean reserved; private final ButtonHandler handler; - private int button; + private int button; // Primary button for backward compatibility + private Set buttons; // Multi-button support private boolean pressed; + + /** + * Helper method to create a button set from a single button + */ + private static Set createButtonSet(int button) + { + return button >= 0 ? new TreeSet<>(Collections.singleton(button)) : new TreeSet<>(); + } public ButtonBinding(int button, String descriptionKey, String category, BindingContext context, ButtonHandler handler) { @@ -29,6 +43,27 @@ public ButtonBinding(int button, String descriptionKey, String category, Binding { this.button = button; this.defaultButton = button; + this.buttons = createButtonSet(button); + this.defaultButtons = new TreeSet<>(this.buttons); + this.descriptionKey = descriptionKey; + this.category = category; + this.context = context; + this.reserved = reserved; + this.handler = handler; + } + + // Constructor for multi-button bindings + public ButtonBinding(Set buttons, String descriptionKey, String category, BindingContext context, ButtonHandler handler) + { + this(buttons, descriptionKey, category, context, false, handler); + } + + ButtonBinding(Set buttons, String descriptionKey, String category, BindingContext context, boolean reserved, ButtonHandler handler) + { + this.buttons = new TreeSet<>(buttons); + this.defaultButtons = new TreeSet<>(this.buttons); + this.button = this.buttons.isEmpty() ? -1 : this.buttons.iterator().next(); // Primary button is first + this.defaultButton = this.button; this.descriptionKey = descriptionKey; this.category = category; this.context = context; @@ -40,6 +75,33 @@ public int getButton() { return this.button; } + + /** + * Returns all buttons required for this binding. + * For single button bindings, this returns a set with one element. + * For multi-button bindings, this returns all buttons in the combination. + */ + public Set getButtons() + { + return Collections.unmodifiableSet(this.buttons); + } + + /** + * Returns true if this binding requires multiple buttons to be pressed simultaneously + */ + public boolean isMultiButton() + { + return this.buttons.size() > 1; + } + + /** + * Returns the number of buttons in this binding. Used for priority sorting so that + * longer combos (e.g. LB+RB+Y) are matched before shorter sub-combos (e.g. Y). + */ + public int getButtonCount() + { + return this.buttons.size(); + } public String getLabelKey() { @@ -63,7 +125,7 @@ public BindingContext getContext() public boolean isDefault() { - return this.button == this.defaultButton; + return this.buttons.equals(this.defaultButtons); } protected void setPressed(boolean pressed) @@ -100,12 +162,21 @@ public boolean isUnbound() public void resetMappedButton() { this.button = this.defaultButton; + this.buttons = new TreeSet<>(this.defaultButtons); } @ApiStatus.Internal public static void setButton(ButtonBinding binding, int button) { binding.button = button; + binding.buttons = createButtonSet(button); + } + + @ApiStatus.Internal + public static void setButtons(ButtonBinding binding, Set buttons) + { + binding.buttons = new TreeSet<>(buttons); + binding.button = binding.buttons.isEmpty() ? -1 : binding.buttons.iterator().next(); } @ApiStatus.Internal @@ -134,11 +205,15 @@ public int compareTo(ButtonBinding o) public boolean isConflictingContext() { - for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(this.button)) + // For multi-button bindings, check all buttons involved + for(int btn : this.buttons) { - if(this.conflicts(binding)) + for(ButtonBinding binding : Controllable.getBindingRegistry().getBindingsForButton(btn)) { - return true; + if(this.conflicts(binding)) + { + return true; + } } } return false; @@ -152,7 +227,18 @@ public boolean isConflictingContext() */ private boolean conflicts(ButtonBinding binding) { - return this != binding && this.button == binding.getButton() && this.context.conflicts(binding.context); + if(this == binding) + return false; + + // Check if there's any overlap in buttons + for(int btn : this.buttons) + { + if(binding.buttons.contains(btn) && this.context.conflicts(binding.context)) + { + return true; + } + } + return false; } @Override diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBindings.java b/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBindings.java index 1520d313..3514ca4d 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBindings.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/binding/ButtonBindings.java @@ -10,9 +10,9 @@ import com.mrcrayfish.controllable.client.binding.handlers.OnPressAndReleaseHandler; import com.mrcrayfish.controllable.client.binding.handlers.OnPressHandler; import com.mrcrayfish.controllable.client.InputHandler; -import com.mrcrayfish.controllable.client.binding.handlers.impl.AttackHandler; import com.mrcrayfish.controllable.client.binding.handlers.impl.DropHandler; import com.mrcrayfish.controllable.client.binding.handlers.impl.SneakHandler; +import com.mrcrayfish.controllable.client.binding.handlers.impl.AttackHandler; import com.mrcrayfish.controllable.client.gui.screens.SettingsScreen; import com.mrcrayfish.controllable.client.input.Buttons; import com.mrcrayfish.controllable.client.util.MouseHooks; @@ -39,6 +39,8 @@ import org.lwjgl.glfw.GLFW; import java.util.Optional; +import java.util.ArrayList; +import java.util.List; /** * Author: MrCrayfish @@ -103,9 +105,7 @@ public class ButtonBindings })); public static final ButtonBinding DROP_ITEM = new ButtonBinding(Buttons.DPAD_DOWN, "key.drop", "key.categories.gameplay", InGameContext.INSTANCE, new DropHandler()); - - public static final ButtonBinding ATTACK = new ButtonBinding(Buttons.RIGHT_TRIGGER, "key.attack", "key.categories.gameplay", InGameContext.INSTANCE, new AttackHandler()); - + public static final ButtonBinding USE_ITEM = new ButtonBinding(Buttons.LEFT_TRIGGER, "key.use", "key.categories.gameplay", InGameContext.INSTANCE, OnPressHandler.create(context -> { return Optional.of(() -> { context.player().ifPresent(player -> { @@ -116,6 +116,8 @@ public class ButtonBindings }); })); + public static final ButtonBinding ATTACK = new ButtonBinding(Buttons.RIGHT_TRIGGER, "key.attack", "key.categories.gameplay", InGameContext.INSTANCE, new AttackHandler()); + public static final ButtonBinding PICK_BLOCK = new ButtonBinding(Buttons.DPAD_LEFT, "key.pickItem", "key.categories.gameplay", InGameContext.INSTANCE, OnPressHandler.create(context -> { return Optional.of(() -> { ClientServices.CLIENT.pickBlock(context.minecraft()); @@ -456,4 +458,5 @@ public class ButtonBindings public static final ButtonBinding LOOK_LEFT = new ButtonBinding(Buttons.RIGHT_THUMB_STICK_LEFT, "controllable.key.look_left", "key.categories.movement", InGameContext.INSTANCE, EmptyHandler.INSTANCE); public static final ButtonBinding LOOK_RIGHT = new ButtonBinding(Buttons.RIGHT_THUMB_STICK_RIGHT, "controllable.key.look_right", "key.categories.movement", InGameContext.INSTANCE, EmptyHandler.INSTANCE); + } diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/binding/KeyAdapterBinding.java b/common/src/main/java/com/mrcrayfish/controllable/client/binding/KeyAdapterBinding.java index 3250190b..ee75f1bf 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/binding/KeyAdapterBinding.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/binding/KeyAdapterBinding.java @@ -7,20 +7,57 @@ import net.minecraft.client.gui.screens.Screen; import org.lwjgl.glfw.GLFW; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Thread-local flag that is {@code true} while a {@link KeyAdapterBinding} is in the process of + * synthesizing and dispatching a keyboard event on behalf of a controller button press/release. + *

+ * Other mods' {@code InputEvent.Key} subscribers can check this flag to avoid double-handling + * actions that already have a dedicated Controllable integration. Example: + *

+ *   if (KeyAdapterBinding.isControllerSynthesizedEvent()) return;
+ * 
+ */ + /** - * A special binding that translates button presses to key presses. This binding does not need to be - * registered and is added by players during runtime. - * + * A special binding that translates button presses to key presses. Supports multi-button. * Author: MrCrayfish */ public final class KeyAdapterBinding extends ButtonBinding { + /** + * Set to {@code true} for the duration of {@link #handlePressed} so that keyboard-event + * subscribers can detect that the event was synthesized by Controllable from a controller + * input rather than from an actual physical key press. + */ + private static final ThreadLocal CONTROLLER_SYNTHESIZED = ThreadLocal.withInitial(() -> false); + + /** + * Returns {@code true} if the current {@code InputEvent.Key} (or {@code InputEvent.MouseButton}) + * being processed was synthesized by a {@link KeyAdapterBinding} on behalf of a controller + * button. Call this at the top of any {@code @SubscribeEvent} handler that should not fire + * when a controller is driving the input. + */ + public static boolean isControllerSynthesizedEvent() + { + return Boolean.TRUE.equals(CONTROLLER_SYNTHESIZED.get()); + } + private final KeyMapping keyMapping; private final String labelKey; public KeyAdapterBinding(int button, KeyMapping mapping) { - super(button, mapping.getName() + ".custom", "key.categories.controllable_custom", ClientServices.CLIENT.createBindingContext(mapping), EmptyHandler.INSTANCE); + this(new HashSet<>(Collections.singleton(button)), mapping); + } + + public KeyAdapterBinding(Set buttons, KeyMapping mapping) + { + // You must call super with buttons and all required params. Adjust signature as in your ButtonBinding. + super(buttons, mapping.getName() + ".custom", "key.categories.controllable_custom", ClientServices.CLIENT.createBindingContext(mapping), EmptyHandler.INSTANCE); this.keyMapping = mapping; this.labelKey = mapping.getName(); } @@ -55,6 +92,31 @@ else if(wasPressed && !pressed) } } + /** + * Overrides the base reset to also clear {@code keyMapping.setDown(false)} and fire a + * synthetic GLFW release event. The base {@link ButtonBinding#resetPressedState()} only + * clears the internal {@code pressed} field directly, bypassing {@link #setPressed} and + * therefore leaving the underlying {@link net.minecraft.client.KeyMapping} stuck in a + * held-down state — which causes mods that poll {@code keyMapping.isDown()} (e.g. Cobblemon + * in its battle UI) to see the key as perpetually held after a screen transition. + */ + @Override + public void resetPressedState() + { + if(this.isButtonDown()) + { + // Use setPressed(false) so keyMapping.setDown(false) and the GLFW release event + // are both fired, cleanly unwinding any held state in downstream mods. + this.setPressed(false); + } + else + { + // Not currently pressed — still ensure the KeyMapping is not stuck down. + this.keyMapping.setDown(false); + super.resetPressedState(); + } + } + private void updateKeyBindPressTime() { ClientServices.CLIENT.setKeyPressTime(this.keyMapping, 1); @@ -62,9 +124,17 @@ private void updateKeyBindPressTime() private void handlePressed(int action, int key, int modifiers) { - Screen screen = Minecraft.getInstance().screen; - if(screen != null && ClientServices.CLIENT.sendScreenInput(screen, key, action, modifiers)) - return; - ClientServices.CLIENT.sendKeyInputEvent(key, 0, action, modifiers); + CONTROLLER_SYNTHESIZED.set(true); + try + { + Screen screen = Minecraft.getInstance().screen; + if(screen != null && ClientServices.CLIENT.sendScreenInput(screen, key, action, modifiers)) + return; + ClientServices.CLIENT.sendKeyInputEvent(key, 0, action, modifiers); + } + finally + { + CONTROLLER_SYNTHESIZED.set(false); + } } -} +} \ No newline at end of file diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/gui/components/ButtonBindingList.java b/common/src/main/java/com/mrcrayfish/controllable/client/gui/components/ButtonBindingList.java index 23f7871b..c6a9705c 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/gui/components/ButtonBindingList.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/gui/components/ButtonBindingList.java @@ -183,7 +183,7 @@ public void render(GuiGraphics graphics, int index, int top, int left, int width super.render(graphics, index, top, left, width, itemHeight, mouseX, mouseY, selected, partialTick); this.bindingButton.setTooltip(ClientHelper.createListTooltip(this.getBindingTooltip(this.binding))); this.bindingButton.setTooltipDelay(Duration.ofMillis(400)); - this.bindingButton.setX(left + width - 65); + this.bindingButton.setX(left + width - 85); this.bindingButton.setY(top); this.bindingButton.render(graphics, mouseX, mouseY, partialTick); this.resetButton.setX(left + width - 24); diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java b/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java index 9f225ffc..6a76ff5c 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/gui/screens/SettingsScreen.java @@ -54,6 +54,7 @@ public class SettingsScreen extends Screen private TabNavigationBar navigationBar; private Button doneButton; private ButtonBinding selectedBinding; + private final java.util.Set pendingButtons = new java.util.TreeSet<>(); private int remainingTime; private int initialTab = 0; @@ -131,6 +132,32 @@ public void tick() if(this.remainingTime <= 0) { this.selectedBinding = null; + this.pendingButtons.clear(); + } + + // Check if all pending buttons have been released + if(!this.pendingButtons.isEmpty()) + { + var controller = Controllable.getController(); + if(controller != null) + { + var states = controller.getTrackedButtonStates(); + boolean anyPressed = false; + for(int button : this.pendingButtons) + { + if(states.getState(button)) + { + anyPressed = true; + break; + } + } + + // If no pending buttons are pressed, finalize the binding + if(!anyPressed) + { + this.finalizeBinding(); + } + } } } } @@ -148,11 +175,38 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi stack.translate(0, 0, 100); graphics.fillGradient(0, 0, this.width, this.height, 0xE0101010, 0xF0101010); ScreenHelper.drawRoundedBox(graphics, (int) (this.width * 0.125), this.height / 4, (int) (this.width * 0.75), this.height / 2, 0x99000000); - Component pressButtonLabel = Component.translatable("controllable.gui.waiting_for_input").withStyle(ChatFormatting.YELLOW); - graphics.drawCenteredString(this.font, pressButtonLabel, this.width / 2, this.height / 2 - 10, 0xFFFFFFFF); + + if(this.pendingButtons.isEmpty()) + { + Component pressButtonLabel = Component.translatable("controllable.gui.waiting_for_input").withStyle(ChatFormatting.YELLOW); + graphics.drawCenteredString(this.font, pressButtonLabel, this.width / 2, this.height / 2 - 10, 0xFFFFFFFF); + } + else + { + Component pressButtonLabel = Component.translatable("controllable.gui.multi_button_input").withStyle(ChatFormatting.GREEN); + graphics.drawCenteredString(this.font, pressButtonLabel, this.width / 2, this.height / 2 - 20, 0xFFFFFFFF); + + // Show the buttons that have been pressed + StringBuilder buttonNames = new StringBuilder(); + for(int button : this.pendingButtons) + { + if(buttonNames.length() > 0) + buttonNames.append(" + "); + String name = com.mrcrayfish.controllable.client.input.Buttons.getNameForButton(button); + if(name != null) + { + buttonNames.append(Component.translatable(name).getString()); + } + } + graphics.drawCenteredString(this.font, Component.literal(buttonNames.toString()), this.width / 2, this.height / 2 - 5, 0xFFFFFFFF); + + Component releaseLabel = Component.translatable("controllable.gui.release_to_confirm").withStyle(ChatFormatting.GRAY); + graphics.drawCenteredString(this.font, releaseLabel, this.width / 2, this.height / 2 + 8, 0xFFFFFFFF); + } + Component time = Component.literal(Integer.toString((int) Math.ceil(this.remainingTime / 20.0))); Component inputCancelLabel = Component.translatable("controllable.gui.input_cancel", time); - graphics.drawCenteredString(this.font, inputCancelLabel, this.width / 2, this.height / 2 + 3, 0xFFFFFFFF); + graphics.drawCenteredString(this.font, inputCancelLabel, this.width / 2, this.height / 2 + 21, 0xFFFFFFFF); stack.popPose(); } } @@ -188,6 +242,7 @@ public void setSelectedBinding(ButtonBinding binding) if(this.tabManager.getCurrentTab() instanceof BindingsTab) { this.selectedBinding = binding; + this.pendingButtons.clear(); this.remainingTime = 100; } } @@ -197,6 +252,7 @@ public boolean isWaitingForButtonInput() if(this.selectedBinding != null && !(this.tabManager.getCurrentTab() instanceof BindingsTab)) { this.selectedBinding = null; + this.pendingButtons.clear(); } return this.selectedBinding != null; } @@ -205,14 +261,35 @@ public boolean processButton(int index) { if(this.selectedBinding != null) { - ButtonBinding.setButton(this.selectedBinding, index); + // Add button to pending set + this.pendingButtons.add(index); + this.remainingTime = 100; // Reset timer when new button is pressed + return true; + } + return false; + } + + private void finalizeBinding() + { + if(this.selectedBinding != null && !this.pendingButtons.isEmpty()) + { + if(this.pendingButtons.size() == 1) + { + // Single button binding + ButtonBinding.setButton(this.selectedBinding, this.pendingButtons.iterator().next()); + } + else + { + // Multi-button binding + ButtonBinding.setButtons(this.selectedBinding, new java.util.TreeSet<>(this.pendingButtons)); + } + this.selectedBinding = null; + this.pendingButtons.clear(); BindingRegistry registry = Controllable.getBindingRegistry(); registry.rebuildCache(); registry.save(); - return true; } - return false; } public class ControllerTab extends GridLayoutTab diff --git a/common/src/main/java/com/mrcrayfish/controllable/client/gui/widget/ButtonBindingButton.java b/common/src/main/java/com/mrcrayfish/controllable/client/gui/widget/ButtonBindingButton.java index 92983a8c..b1b9fe66 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/client/gui/widget/ButtonBindingButton.java +++ b/common/src/main/java/com/mrcrayfish/controllable/client/gui/widget/ButtonBindingButton.java @@ -9,6 +9,9 @@ import net.minecraft.client.gui.components.Button; import net.minecraft.network.chat.CommonComponents; +import java.util.ArrayList; +import java.util.List; + /** * Author: MrCrayfish */ @@ -16,10 +19,11 @@ public class ButtonBindingButton extends Button { private final ButtonBinding binding; private final ButtonOnPress onPress; + private static final int MAX_DISPLAYED_BUTTONS = 3; // Maximum buttons to show as icons public ButtonBindingButton(int x, int y, ButtonBinding binding, ButtonOnPress onPress) { - super(x, y, 40, 20, CommonComponents.EMPTY, btn -> {}, DEFAULT_NARRATION); + super(x, y, 65, 20, CommonComponents.EMPTY, btn -> {}, DEFAULT_NARRATION); this.binding = binding; this.onPress = onPress; } @@ -33,13 +37,65 @@ public ButtonBinding getBinding() public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTicks) { super.renderWidget(graphics, mouseX, mouseY, partialTicks); - if(this.binding.getButton() < 0) + + if(this.binding.isUnbound()) return; - int texU = this.binding.getButton() * 13; - int texV = Config.CLIENT.options.controllerIcons.get().ordinal() * 13; - int size = 13; - RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); - graphics.blit(ButtonIcons.TEXTURE, this.getX() + (this.width - size) / 2 + 1, this.getY() + 3, texU, texV, size, size, ButtonIcons.TEXTURE_WIDTH, ButtonIcons.TEXTURE_HEIGHT); + + if(this.binding.isMultiButton()) + { + // Get all buttons in the binding + List buttons = new ArrayList<>(this.binding.getButtons()); + int buttonCount = buttons.size(); + int displayCount = Math.min(buttonCount, MAX_DISPLAYED_BUTTONS); + + int texV = Config.CLIENT.options.controllerIcons.get().ordinal() * 13; + int size = 13; + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); + + // Calculate total width needed for all icons + plus signs + int iconWidth = size; + int plusWidth = 4; // Width of "+" character + int spacing = 2; + int totalWidth = (iconWidth * displayCount) + (plusWidth * (displayCount - 1)) + (spacing * (displayCount - 1)); + + // Starting X position to center everything + int startX = this.getX() + (this.width - totalWidth) / 2; + int currentX = startX; + + // Draw each button icon with "+" between them + for(int i = 0; i < displayCount; i++) + { + int button = buttons.get(i); + int texU = button * 13; + + // Draw button icon + graphics.blit(ButtonIcons.TEXTURE, currentX, this.getY() + 3, texU, texV, size, size, ButtonIcons.TEXTURE_WIDTH, ButtonIcons.TEXTURE_HEIGHT); + currentX += iconWidth + spacing; + + // Draw "+" between icons (but not after the last one) + if(i < displayCount - 1) + { + graphics.drawString(Minecraft.getInstance().font, "+", currentX, this.getY() + 6, 0xFFFFFFFF, false); + currentX += plusWidth + spacing; + } + } + + // If there are more buttons than we can display, show "+N" at the end + if(buttonCount > MAX_DISPLAYED_BUTTONS) + { + int remaining = buttonCount - MAX_DISPLAYED_BUTTONS; + graphics.drawString(Minecraft.getInstance().font, "+" + remaining, currentX + 2, this.getY() + 6, 0xAAAAAAAA, false); + } + } + else + { + // Single button binding - original behavior + int texU = this.binding.getButton() * 13; + int texV = Config.CLIENT.options.controllerIcons.get().ordinal() * 13; + int size = 13; + RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F); + graphics.blit(ButtonIcons.TEXTURE, this.getX() + (this.width - size) / 2 + 1, this.getY() + 3, texU, texV, size, size, ButtonIcons.TEXTURE_WIDTH, ButtonIcons.TEXTURE_HEIGHT); + } } @Override @@ -60,4 +116,4 @@ public interface ButtonOnPress { boolean onPress(int button); } -} +} \ No newline at end of file diff --git a/common/src/main/java/com/mrcrayfish/controllable/mixin/client/MinecraftMixin.java b/common/src/main/java/com/mrcrayfish/controllable/mixin/client/MinecraftMixin.java index 40a16dae..5f640e0b 100644 --- a/common/src/main/java/com/mrcrayfish/controllable/mixin/client/MinecraftMixin.java +++ b/common/src/main/java/com/mrcrayfish/controllable/mixin/client/MinecraftMixin.java @@ -4,9 +4,9 @@ import com.mrcrayfish.controllable.Config; import com.mrcrayfish.controllable.Controllable; import com.mrcrayfish.controllable.client.binding.ButtonBindings; -import com.mrcrayfish.controllable.client.binding.handlers.impl.AttackHandler; import com.mrcrayfish.controllable.client.input.Controller; import com.mrcrayfish.controllable.platform.ClientServices; +import com.mrcrayfish.controllable.client.binding.handlers.impl.AttackHandler; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; import org.spongepowered.asm.mixin.Mixin; diff --git a/common/src/main/resources/assets/controllable/lang/en_us.json b/common/src/main/resources/assets/controllable/lang/en_us.json index 69f0f4e9..c50ac044 100644 --- a/common/src/main/resources/assets/controllable/lang/en_us.json +++ b/common/src/main/resources/assets/controllable/lang/en_us.json @@ -134,6 +134,8 @@ "controllable.gui.update_mapping_message": "Controllable will download the latest gamepad mappings from GitHub with the following url: %s", "controllable.gui.update_mappings": "Update Mappings", "controllable.gui.waiting_for_input": "Listening for button input...", + "controllable.gui.multi_button_input": "Press additional buttons for combination...", + "controllable.gui.release_to_confirm": "Release all buttons to confirm", "controllable.key.close_inventory": "Close Inventory", "controllable.key.debug_info": "Show Debug Info", "controllable.key.hotbar_slot_1": "Hotbar Slot #1", diff --git a/fabric/build.gradle b/fabric/build.gradle index d2075077..bce27f2b 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -5,20 +5,21 @@ plugins { id 'fabric-loom' version '1.7-SNAPSHOT' } +repositories { + maven { url 'https://maven.mrcrayfish.com/' } +} + dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${fabric_loader_version}" modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_version}" - modImplementation "com.mrcrayfish:framework-fabric:${minecraft_version}-${framework_version}" + modImplementation files('libs/framework-fabric-1.21.1-0.13.11-signed.jar') modCompileOnly "dev.architectury:architectury-fabric:${architectury_api_version}" modCompileOnly "mezz.jei:jei-${jei_minecraft_version}-fabric:${jei_version}" modCompileOnly "dev.emi:emi-fabric:${emi_version}" modCompileOnly "me.shedaniel:RoughlyEnoughItems-fabric:${rei_version}" - //modRuntimeOnly "squeek.appleskin:appleskin-fabric:mc1.21.3-3.0.6" - library include("com.mrcrayfish:controllable-sdl:${controllable_sdl_version}") { - transitive(false) - } + library files('libs/controllable-sdl-2.32.10-1.1.0.jar') implementation "com.google.code.findbugs:jsr305:3.0.1" } diff --git a/fabric/libs/controllable-sdl-2.32.10-1.1.0.jar b/fabric/libs/controllable-sdl-2.32.10-1.1.0.jar new file mode 100644 index 00000000..8344a033 Binary files /dev/null and b/fabric/libs/controllable-sdl-2.32.10-1.1.0.jar differ diff --git a/fabric/libs/framework-fabric-1.21.1-0.13.11-signed.jar b/fabric/libs/framework-fabric-1.21.1-0.13.11-signed.jar new file mode 100644 index 00000000..489f8dc6 Binary files /dev/null and b/fabric/libs/framework-fabric-1.21.1-0.13.11-signed.jar differ diff --git a/gradle.properties b/gradle.properties index 48e4e3dd..55845784 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,7 +29,7 @@ mod_issues=https://github.com/MrCrayfish/Controllable/issues mod_license=MIT # Dependency options -controllable_sdl_version=2.32.8-1.1.0 +controllable_sdl_version=2.32.10-1.1.0 framework_version=0.9.6 architectury_api_version=13.0.8 jei_version=19.27.0.336 diff --git a/neoforge/build.gradle b/neoforge/build.gradle index 38f28031..9443338b 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -12,12 +12,6 @@ if (at.exists()) { jarJar.enable() -/*mixin { - add sourceSets.main, "controllable.refmap.json" - config 'controllable.common.mixins.json' - config 'controllable.neoforge.mixins.json' -}*/ - sourceSets { test { compileClasspath += project(':common').sourceSets.main.output @@ -33,9 +27,6 @@ configurations { runs { configureEach { modSource project.sourceSets.main - dependencies { - runtime project.configurations.library - } } client { systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id @@ -60,48 +51,56 @@ sourceSets.main.resources { srcDir 'src/generated/resources' } dependencies { // Core dependencies implementation "net.neoforged:neoforge:${neoforge_version}" - implementation "com.mrcrayfish:framework-neoforge:${minecraft_version}-${framework_version}" + implementation files('libs/framework-neoforge-1.21.1-0.13.11-signed.jar') + implementation files('libs/ShoulderSurfing-NeoForge-1.21.1-4.16.1.jar') - // JEI + // JEI, EMI, REI... compileOnly "mezz.jei:jei-${minecraft_version}-common-api:${jei_version}" compileOnly "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}" - - // EMI compileOnly "dev.emi:emi-neoforge:${emi_version}" - - // Roughly Enough Items compileOnly "me.shedaniel:RoughlyEnoughItems-neoforge:${rei_version}" - // Controllable SDL (https://github.com/MrCrayfish/ControllableSDL) - library jarJar("com.mrcrayfish:controllable-sdl:${controllable_sdl_version}") { - transitive(false) - } - + // Controllable SDL + implementation files('libs/controllable-sdl-2.32.10-1.1.0.jar') + jarJar(files('libs/controllable-sdl-2.32.10-1.1.0.jar')) testImplementation "org.junit.jupiter:junit-jupiter:5.7.2" testRuntimeOnly "org.junit.platform:junit-platform-launcher" } -tasks.jarJar.configure { +// Custom task to manually bundle SDL into the jar +tasks.register('bundleSDL', Jar) { + dependsOn jar archiveClassifier = 'release' + from zipTree(jar.archiveFile) + from(zipTree('libs/controllable-sdl-2.32.10-1.1.0.jar')) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(rootProject.file("LICENSE_LibSDL4J")) from(rootProject.file("LICENSE")) { rename { "${it}_${mod_name}" } } + + manifest { + from jar.manifest + } } +build.dependsOn bundleSDL + tasks.register('signJar', PotentiallySignJar) { - dependsOn jar + dependsOn bundleSDL onlyIf { hasProperty('keyStore') || System.getenv("KEYSTORE") } keyStore = findProperty('keyStore') ?: System.getenv("KEYSTORE") alias = findProperty('keyStoreAlias') ?: System.getenv("KEYSTORE_ALIAS") storePass = findProperty('keyStorePass') ?: System.getenv("KEYSTORE_PASS") - input = jar.archiveFile + input = bundleSDL.archiveFile } -jar.finalizedBy 'signJar' +jar.finalizedBy bundleSDL +bundleSDL.finalizedBy 'signJar' idea { module { @@ -112,4 +111,4 @@ idea { test { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/neoforge/libs/ShoulderSurfing-NeoForge-1.21.1-4.16.1.jar b/neoforge/libs/ShoulderSurfing-NeoForge-1.21.1-4.16.1.jar new file mode 100644 index 00000000..7aab3b93 Binary files /dev/null and b/neoforge/libs/ShoulderSurfing-NeoForge-1.21.1-4.16.1.jar differ diff --git a/neoforge/libs/controllable-sdl-2.32.10-1.1.0.jar b/neoforge/libs/controllable-sdl-2.32.10-1.1.0.jar new file mode 100644 index 00000000..8344a033 Binary files /dev/null and b/neoforge/libs/controllable-sdl-2.32.10-1.1.0.jar differ diff --git a/neoforge/libs/framework-neoforge-1.21.1-0.13.11-signed.jar b/neoforge/libs/framework-neoforge-1.21.1-0.13.11-signed.jar new file mode 100644 index 00000000..895ecf8a Binary files /dev/null and b/neoforge/libs/framework-neoforge-1.21.1-0.13.11-signed.jar differ diff --git a/neoforge/src/main/java/com/mrcrayfish/controllable/ControllableMod.java b/neoforge/src/main/java/com/mrcrayfish/controllable/ControllableMod.java index 5ed0640f..88ca8fa7 100644 --- a/neoforge/src/main/java/com/mrcrayfish/controllable/ControllableMod.java +++ b/neoforge/src/main/java/com/mrcrayfish/controllable/ControllableMod.java @@ -1,6 +1,7 @@ package com.mrcrayfish.controllable; import com.mrcrayfish.controllable.client.ClientBootstrap; +import com.mrcrayfish.controllable.compat.ShoulderSurfingCompat; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; @@ -17,6 +18,7 @@ public class ControllableMod private static void onClientSetup(FMLClientSetupEvent event) { event.enqueueWork(ClientBootstrap::init); + // ShoulderSurfingCompat.init(); <-- Remove from here! } @SubscribeEvent @@ -24,8 +26,9 @@ private static void onLoadComplete(FMLLoadCompleteEvent event) { event.enqueueWork(() -> { Controllable.getBindingRegistry().completeSetup(); + ShoulderSurfingCompat.init(); Controllable.getControllerManager().completeSetup(); Controllable.getCursor().resetToCenter(); }); } -} +} \ No newline at end of file diff --git a/neoforge/src/main/java/com/mrcrayfish/controllable/compat/ShoulderSurfingCompat.java b/neoforge/src/main/java/com/mrcrayfish/controllable/compat/ShoulderSurfingCompat.java new file mode 100644 index 00000000..989a4fba --- /dev/null +++ b/neoforge/src/main/java/com/mrcrayfish/controllable/compat/ShoulderSurfingCompat.java @@ -0,0 +1,54 @@ +package com.mrcrayfish.controllable.compat; + +import com.mrcrayfish.controllable.Controllable; +import com.mrcrayfish.controllable.client.binding.KeyAdapterBinding; +import net.neoforged.fml.ModList; +import net.minecraft.client.KeyMapping; + +public class ShoulderSurfingCompat +{ + public static void init() + { + if (isShoulderSurfingLoaded()) + { + boolean addedAny = false; + try + { + Class inputHandler = Class.forName("com.github.exopandora.shouldersurfing.client.InputHandler"); + String[] fields = { + "CAMERA_LEFT", "CAMERA_RIGHT", "CAMERA_IN", "CAMERA_OUT", "CAMERA_UP", "CAMERA_DOWN", + "SWAP_SHOULDER", "TOGGLE_FIRST_PERSON", "TOGGLE_THIRD_PERSON_FRONT", "TOGGLE_THIRD_PERSON_BACK", + "FREE_LOOK", "TOGGLE_CAMERA_COUPLING", "TOGGLE_X_OFFSET_PRESETS", "TOGGLE_Y_OFFSET_PRESETS", + "TOGGLE_Z_OFFSET_PRESETS", "ENTER_FIRST_PERSON", "ENTER_THIRD_PERSON_FRONT", + "ENTER_THIRD_PERSON_BACK", "ENTER_SHOULDER_SURFING" + }; + + for (String field : fields) + { + KeyMapping keyMapping = (KeyMapping) inputHandler.getField(field).get(null); + // add as unbound if not present + KeyAdapterBinding adapter = new KeyAdapterBinding(-1, keyMapping); + if (Controllable.getBindingRegistry().getKeyAdapters().get(adapter.getDescription()) == null) + { + Controllable.getBindingRegistry().addKeyAdapter(adapter); + addedAny = true; + } + } + if (addedAny) + { + // THIS is what actually writes ShoulderSurfing binds to key_adapters.json. + Controllable.getBindingRegistry().save(); + } + } + catch (Throwable t) + { + // Swallow exceptions—compat is best-effort only! + } + } + } + + private static boolean isShoulderSurfingLoaded() + { + return ModList.get().isLoaded("shouldersurfing"); + } +} \ No newline at end of file