diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 62d951e..18738cb 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -1,6 +1,7 @@ package dev.isxander.controlify; import com.mojang.logging.LogUtils; +import dev.isxander.controlify.gui.screen.ControllerDeadzoneCalibrationScreen; import dev.isxander.controlify.screenop.ScreenProcessorProvider; import dev.isxander.controlify.config.ControlifyConfig; import dev.isxander.controlify.controller.Controller; @@ -14,10 +15,14 @@ import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.toasts.SystemToast; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import org.lwjgl.glfw.GLFW; import org.slf4j.Logger; +import java.util.ArrayDeque; +import java.util.Queue; + public class Controlify { public static final Logger LOGGER = LogUtils.getLogger(); private static Controlify instance = null; @@ -31,6 +36,8 @@ public class Controlify { private final ControlifyConfig config = new ControlifyConfig(); + private final Queue calibrationQueue = new ArrayDeque<>(); + public void onInitializeInput() { Minecraft minecraft = Minecraft.getInstance(); @@ -44,7 +51,10 @@ public class Controlify { controllerHIDService.awaitNextController(device -> { setCurrentController(Controller.create(jid, device)); LOGGER.info("Controller found: " + currentController.name()); - config().loadOrCreateControllerData(currentController); + + if (!config().loadOrCreateControllerData(currentController)) { + calibrationQueue.add(currentController); + } }); } } @@ -61,7 +71,9 @@ public class Controlify { LOGGER.info("Controller connected: " + currentController.name()); this.setCurrentInputMode(InputMode.CONTROLLER); - config().loadOrCreateControllerData(currentController); + if (!config().loadOrCreateControllerData(currentController)) { + calibrationQueue.add(currentController); + } minecraft.getToasts().addToast(SystemToast.multiline( minecraft, @@ -94,6 +106,24 @@ public class Controlify { } public void tick(Minecraft client) { + var minecraft = Minecraft.getInstance(); + if (minecraft.getOverlay() == null) { + if (!calibrationQueue.isEmpty()) { + Screen screen = minecraft.screen; + while (!calibrationQueue.isEmpty()) { + screen = new ControllerDeadzoneCalibrationScreen(calibrationQueue.poll(), screen); + } + minecraft.setScreen(screen); + + minecraft.getToasts().addToast(SystemToast.multiline( + minecraft, + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + Component.translatable("controlify.toast.controller_calibration.title"), + Component.translatable("controlify.toast.controller_calibration.description") + )); + } + } + for (Controller controller : Controller.CONTROLLERS.values()) { controller.updateState(); } diff --git a/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java b/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java index d574985..b6ee87a 100644 --- a/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java +++ b/src/main/java/dev/isxander/controlify/config/ControlifyConfig.java @@ -91,12 +91,14 @@ public class ControlifyConfig { } } - public void loadOrCreateControllerData(Controller controller) { + public boolean loadOrCreateControllerData(Controller controller) { var uid = controller.uid(); if (controllerData.has(uid)) { applyControllerConfig(controller, controllerData.getAsJsonObject(uid)); + return true; } else { save(); + return false; } } diff --git a/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java b/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java index f57b47e..2288065 100644 --- a/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java +++ b/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java @@ -5,6 +5,7 @@ import dev.isxander.controlify.bindings.IBind; import dev.isxander.controlify.config.GlobalSettings; import dev.isxander.controlify.controller.ControllerTheme; import dev.isxander.controlify.controller.Controller; +import dev.isxander.controlify.gui.screen.ControllerDeadzoneCalibrationScreen; import dev.isxander.yacl.api.*; import dev.isxander.yacl.gui.controllers.ActionController; import dev.isxander.yacl.gui.controllers.BooleanController; @@ -153,6 +154,12 @@ public class YACLHelper { .binding(def.rightStickDeadzone, () -> config.rightStickDeadzone, v -> config.rightStickDeadzone = v) .controller(opt -> new FloatSliderController(opt, 0, 1, 0.01f, v -> Component.literal(String.format("%.0f%%", v*100)))) .build()) + .option(ButtonOption.createBuilder() + .name(Component.translatable("controlify.gui.auto_calibration")) + .tooltip(Component.translatable("controlify.gui.auto_calibration.tooltip")) + .action((screen, button) -> Minecraft.getInstance().setScreen(new ControllerDeadzoneCalibrationScreen(controller, screen))) + .controller(ActionController::new) + .build()) .option(Option.createBuilder(float.class) .name(Component.translatable("controlify.gui.button_activation_threshold")) .tooltip(Component.translatable("controlify.gui.button_activation_threshold.tooltip")) diff --git a/src/main/java/dev/isxander/controlify/controller/Controller.java b/src/main/java/dev/isxander/controlify/controller/Controller.java index 01f6825..a6822a4 100644 --- a/src/main/java/dev/isxander/controlify/controller/Controller.java +++ b/src/main/java/dev/isxander/controlify/controller/Controller.java @@ -1,5 +1,6 @@ package dev.isxander.controlify.controller; +import dev.isxander.controlify.Controlify; import dev.isxander.controlify.bindings.ControllerBindings; import dev.isxander.controlify.controller.hid.HIDIdentifier; import org.hid4java.HidDevice; @@ -57,17 +58,18 @@ public final class Controller { prevState = state; - AxesState axesState = AxesState.fromController(this) + AxesState rawAxesState = AxesState.fromController(this); + AxesState axesState = rawAxesState .leftJoystickDeadZone(config().leftStickDeadzone, config().leftStickDeadzone) .rightJoystickDeadZone(config().rightStickDeadzone, config().rightStickDeadzone) .leftTriggerDeadZone(config().leftTriggerDeadzone) .rightTriggerDeadZone(config().rightTriggerDeadzone); ButtonState buttonState = ButtonState.fromController(this); - state = new ControllerState(axesState, buttonState); + state = new ControllerState(axesState, rawAxesState, buttonState); } public void consumeButtonState() { - this.state = new ControllerState(state().axes(), ButtonState.EMPTY); + this.state = new ControllerState(state().axes(), state().rawAxes(), ButtonState.EMPTY); } public ControllerBindings bindings() { @@ -147,13 +149,15 @@ public final class Controller { String fallbackName = gamepad ? GLFW.glfwGetGamepadName(id) : GLFW.glfwGetJoystickName(id); String uid = device != null ? UUID.nameUUIDFromBytes(device.getPath().getBytes(StandardCharsets.UTF_8)).toString() : "unidentified-" + UUID.randomUUID(); ControllerType type = device != null ? ControllerType.getTypeForHID(new HIDIdentifier(device.getVendorId(), device.getProductId())) : ControllerType.UNKNOWN; - String name = type != ControllerType.UNKNOWN || fallbackName == null ? type.friendlyName() : fallbackName; + String ogName = type != ControllerType.UNKNOWN || fallbackName == null ? type.friendlyName() : fallbackName; + String name = ogName; int tries = 1; while (CONTROLLERS.values().stream().map(Controller::name).anyMatch(name::equals)) { - name = type.friendlyName() + " (" + tries++ + ")"; + name = ogName + " (" + tries++ + ")"; } Controller controller = new Controller(id, guid, name, gamepad, uid, type); + CONTROLLERS.put(id, controller); return controller; diff --git a/src/main/java/dev/isxander/controlify/controller/ControllerState.java b/src/main/java/dev/isxander/controlify/controller/ControllerState.java index 5e12b09..3416dc0 100644 --- a/src/main/java/dev/isxander/controlify/controller/ControllerState.java +++ b/src/main/java/dev/isxander/controlify/controller/ControllerState.java @@ -1,9 +1,9 @@ package dev.isxander.controlify.controller; -public record ControllerState(AxesState axes, ButtonState buttons) { - public static final ControllerState EMPTY = new ControllerState(AxesState.EMPTY, ButtonState.EMPTY); +public record ControllerState(AxesState axes, AxesState rawAxes, ButtonState buttons) { + public static final ControllerState EMPTY = new ControllerState(AxesState.EMPTY, AxesState.EMPTY, ButtonState.EMPTY); public boolean hasAnyInput() { - return !this.equals(EMPTY); + return !this.axes().equals(AxesState.EMPTY) || !this.buttons().equals(ButtonState.EMPTY); } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerDeadzoneCalibrationScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/ControllerDeadzoneCalibrationScreen.java new file mode 100644 index 0000000..369581b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/gui/screen/ControllerDeadzoneCalibrationScreen.java @@ -0,0 +1,160 @@ +package dev.isxander.controlify.gui.screen; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import dev.isxander.controlify.controller.Controller; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.MultiLineLabel; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; + +import java.math.RoundingMode; + +public class ControllerDeadzoneCalibrationScreen extends Screen { + private static final ResourceLocation GUI_BARS_LOCATION = new ResourceLocation("textures/gui/bars.png"); + + protected final Controller controller; + private final Screen parent; + + private MultiLineLabel waitLabel, infoLabel, completeLabel; + + protected Button readyButton; + + protected boolean calibrating = false, calibrated = false; + protected int calibrationTicks = 0; + + public ControllerDeadzoneCalibrationScreen(Controller controller, Screen parent) { + super(Component.translatable("controlify.calibration.title")); + this.controller = controller; + this.parent = parent; + } + + @Override + protected void init() { + addRenderableWidget( + readyButton = Button.builder(Component.translatable("controlify.calibration.ready"), btn -> { + if (!calibrated) + startCalibration(); + else + onClose(); + }) + .width(150) + .pos(this.width / 2 - 75, this.height - 8 - 20) + .build()); + + this.infoLabel = MultiLineLabel.create(font, Component.translatable("controlify.calibration.info"), width - 30); + this.waitLabel = MultiLineLabel.create(font, Component.translatable("controlify.calibration.wait"), width - 30); + this.completeLabel = MultiLineLabel.create(font, Component.translatable("controlify.calibration.complete"), width - 30); + } + + protected void startCalibration() { + calibrating = true; + + readyButton.active = false; + readyButton.setMessage(Component.translatable("controlify.calibration.calibrating")); + } + + @Override + public void render(PoseStack matrices, int mouseX, int mouseY, float delta) { + renderBackground(matrices); + + super.render(matrices, mouseX, mouseY, delta); + + drawCenteredString(matrices, font, Component.translatable("controlify.calibration.title", controller.name()).withStyle(ChatFormatting.BOLD), width / 2, 8, -1); + + RenderSystem.setShaderTexture(0, GUI_BARS_LOCATION); + RenderSystem.setShaderColor(1f, 1f, 1f, 1f); + matrices.pushPose(); + matrices.scale(2f, 2f, 1f); + drawBar(matrices, width / 2 / 2, 30 / 2, 1f, 0); + var progress = (calibrationTicks - 1 + delta) / 100f; + if (progress > 0) + drawBar(matrices, width / 2 / 2, 30 / 2, progress, 5); + matrices.popPose(); + + MultiLineLabel label; + if (calibrating) label = waitLabel; + else if (calibrated) label = completeLabel; + else label = infoLabel; + + label.renderCentered(matrices, width / 2, 55); + } + + private void drawBar(PoseStack matrices, int centerX, int y, float progress, int vOffset) { + progress = 1 - (float)Math.pow(1 - progress, 3); + + int x = centerX - 182 / 2; + this.blit(matrices, x, y, 0, 30 + vOffset, (int)(progress * 182), 5); + } + + @Override + public void tick() { + if (!calibrating) + return; + + if (stateChanged()) + calibrationTicks = 0; + + if (calibrationTicks < 100) { + calibrationTicks++; + } else { + useCurrentStateAsDeadzone(); + calibrating = false; + calibrated = true; + readyButton.active = true; + readyButton.setMessage(Component.translatable("controlify.calibration.done")); + } + } + + private void useCurrentStateAsDeadzone() { + var rawAxes = controller.state().rawAxes(); + + var minDeadzoneLS = Math.max(rawAxes.leftStickX(), rawAxes.leftStickY()) + 0.08f; + var deadzoneLS = (float)Mth.clamp(0.05 * Math.ceil(minDeadzoneLS / 0.05), 0, 0.95); + + var minDeadzoneRS = Math.max(rawAxes.rightStickX(), rawAxes.rightStickY()) + 0.08f; + var deadzoneRS = (float)Mth.clamp(0.05 * Math.ceil(minDeadzoneRS / 0.05), 0, 0.95); + + controller.config().leftStickDeadzone = deadzoneLS; + controller.config().rightStickDeadzone = deadzoneRS; + } + + private boolean stateChanged() { + var amt = 0.0001f; + + var lsX = controller.state().rawAxes().leftStickX(); + var prevLsX = controller.prevState().rawAxes().leftStickX(); + if (Math.abs(lsX - prevLsX) > amt) + return true; + + var lsY = controller.state().rawAxes().leftStickY(); + var prevLsY = controller.prevState().rawAxes().leftStickY(); + if (Math.abs(lsY - prevLsY) > amt) + return true; + + var rsX = controller.state().rawAxes().rightStickX(); + var prevRsX = controller.prevState().rawAxes().rightStickX(); + if (Math.abs(rsX - prevRsX) > amt) + return true; + + var rsY = controller.state().rawAxes().rightStickY(); + var prevRsY = controller.prevState().rawAxes().rightStickY(); + if (Math.abs(rsY - prevRsY) > amt) + return true; + + return false; + } + + @Override + public void onClose() { + minecraft.setScreen(parent); + } + + @Override + public boolean shouldCloseOnEsc() { + return false; + } +} diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index 1a1b067..7b82ec1 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -16,6 +16,8 @@ "controlify.gui.toggle_sneak.tooltip": "How the state of the sneak button behaves.", "controlify.gui.toggle_sprint": "Sprint", "controlify.gui.toggle_sprint.tooltip": "How the state of the sprint button behaves.", + "controlify.gui.auto_jump": "Auto Jump", + "controlify.gui.auto_jump.tooltip": "If the player should automatically jump when you reach a block.", "controlify.gui.show_guide": "Show Button Guide", "controlify.gui.show_guide.tooltip": "Show a HUD in-game displaying actions you can do with controller buttons.", "controlify.gui.vmouse_sensitivity": "Virtual Mouse Sensitivity", @@ -33,6 +35,8 @@ "controlify.gui.right_stick_deadzone": "Right Stick Deadzone", "controlify.gui.right_stick_deadzone.tooltip": "How far the right joystick needs to be pushed before registering input.", "controlify.gui.stickdrift_warning": "Warning: Setting this too low will cause stickdrift! This is where the internals of your controller become mis-calibrated and register small amounts of input when there shouldn't be.", + "controlify.gui.auto_calibration": "Automatic Deadzone Calibration", + "controlify.gui.auto_calibration.tooltip": "Automatically calibrate the deadzone of your controller.", "controlify.gui.button_activation_threshold": "Button Activation Threshold", "controlify.gui.button_activation_threshold.tooltip": "How far a button needs to be pushed before registering as pressed.", @@ -58,6 +62,8 @@ "controlify.toast.controller_connected.description": "A controller named '%s' has just been connected. You can switch to your other controller in Controlify settings.", "controlify.toast.controller_disconnected.title": "Controller Disconnected", "controlify.toast.controller_disconnected.description": "'%s' was disconnected.", + "controlify.toast.controller_calibration.title": "New controller detected", + "controlify.toast.controller_calibration.description": "A new controller(s) has been detected, you must calibrate before you use it!", "controlify.controller_theme.xbox_one": "Xbox", "controlify.controller_theme.dualshock4": "PS4", @@ -76,6 +82,7 @@ "controlify.binding.controlify.pause": "Pause Game", "controlify.binding.controlify.inventory": "Open Inventory", "controlify.binding.controlify.change_perspective": "Change Perspective", + "controlify.binding.controlify.swap_hands": "Swap Hands", "controlify.binding.controlify.open_chat": "Open Chat", "controlify.binding.controlify.gui_press": "GUI Press", "controlify.binding.controlify.gui_back": "GUI Back", @@ -85,13 +92,13 @@ "controlify.binding.controlify.pick_block": "Pick Block", "controlify.binding.controlify.toggle_hud_visibility": "Toggle HUD Visibility", "controlify.binding.controlify.show_player_list": "Show Player List", - "controlify.binding.controlify.vmouse_lclick": "Virtual Mouse LClick", - "controlify.binding.controlify.vmouse_rclick": "Virtual Mouse RClick", - "controlify.binding.controlify.vmouse_mclick": "Virtual Mouse MClick", - "controlify.binding.controlify.vmouse_scroll_up": "Virtual Mouse Scroll Up", - "controlify.binding.controlify.vmouse_scroll_down": "Virtual Mouse Scroll Down", - "controlify.binding.controlify.vmouse_escape": "Virtual Mouse Key Escape", - "controlify.binding.controlify.vmouse_shift": "Virtual Mouse Key Shift", + "controlify.binding.controlify.vmouse_lclick": "VMouse LClick", + "controlify.binding.controlify.vmouse_rclick": "VMouse RClick", + "controlify.binding.controlify.vmouse_shift_click": "VMouse Shift Click", + "controlify.binding.controlify.vmouse_scroll_up": "VMouse Scroll Up", + "controlify.binding.controlify.vmouse_scroll_down": "VMouse Scroll Down", + "controlify.binding.controlify.vmouse_escape": "VMouse Key Escape", + "controlify.binding.controlify.vmouse_shift": "VMouse Key Shift", "controlify.binding.controlify.vmouse_toggle": "Toggle Virtual Mouse", "controlify.guide.inventory": "Open Inventory", @@ -105,12 +112,21 @@ "controlify.guide.dismount": "Dismount", "controlify.guide.swim_down": "Swim Down", "controlify.guide.drop": "Drop Item", + "controlify.guide.swap_hands": "Swap Hands", "controlify.guide.attack": "Attack", "controlify.guide.break": "Break", "controlify.guide.use": "Use", "controlify.guide.interact": "Interact", "controlify.guide.pick_block": "Pick Block", + "controlify.calibration.title": "Controller Calibration for '%s'", + "controlify.calibration.info": "This process will optimize settings for your controller to prevent stick drift. Stick drift happens in your controller thumbsticks and outputs slightly wrong values when you aren't touching them at all. Deadzones are used to prevent this.\n\nThis will only take a few seconds.", + "controlify.calibration.wait": "Please do not touch your controller thumbsticks until the progress bar is complete. This process will only take a few seconds.", + "controlify.calibration.complete": "Calibration complete! You can now use your controller. Press done to return to the game.", + "controlify.calibration.ready": "Ready", + "controlify.calibration.done": "Done", + "controlify.calibration.calibrating": "Calibrating...", + "controlify.beta.title": "Controlify Beta Notice", "controlify.beta.message": "You are currently using Controlify Beta.\n\nThis mod is a work in progress and will contain many bugs. Please, if you spot a bug in this mod or have a suggestion to make it even better, please create an issue on the %s!\n\nYou can always find the link to the issue tracker in Controlify's settings menu.", "controlify.beta.message.link": "issue tracker",