package dev.isxander.controlify; import com.mojang.blaze3d.Blaze3D; import com.mojang.logging.LogUtils; import dev.isxander.controlify.api.ControlifyApi; import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint; import dev.isxander.controlify.controller.Controller; import dev.isxander.controlify.controller.ControllerState; import dev.isxander.controlify.controller.joystick.CompoundJoystickController; import dev.isxander.controlify.controller.sdl2.SDL2NativesManager; import dev.isxander.controlify.debug.DebugProperties; import dev.isxander.controlify.gui.screen.ControllerDeadzoneCalibrationScreen; import dev.isxander.controlify.gui.screen.VibrationOnboardingScreen; import dev.isxander.controlify.screenop.ScreenProcessorProvider; import dev.isxander.controlify.config.ControlifyConfig; import dev.isxander.controlify.controller.hid.ControllerHIDService; import dev.isxander.controlify.api.event.ControlifyEvents; import dev.isxander.controlify.ingame.guide.InGameButtonGuide; import dev.isxander.controlify.ingame.InGameInputHandler; import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor; import dev.isxander.controlify.utils.DebugLog; import dev.isxander.controlify.utils.ToastUtils; import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.CrashReport; import net.minecraft.CrashReportCategory; import net.minecraft.ReportedException; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.NotNull; import org.lwjgl.glfw.GLFW; import org.slf4j.Logger; import java.util.ArrayDeque; import java.util.Queue; public class Controlify implements ControlifyApi { public static final Logger LOGGER = LogUtils.getLogger(); private static Controlify instance = null; private final Minecraft minecraft = Minecraft.getInstance(); private Controller currentController = Controller.DUMMY; private InGameInputHandler inGameInputHandler; public InGameButtonGuide inGameButtonGuide; private VirtualMouseHandler virtualMouseHandler; private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE; private ControllerHIDService controllerHIDService; private final ControlifyConfig config = new ControlifyConfig(this); private final Queue> calibrationQueue = new ArrayDeque<>(); private int consecutiveInputSwitches = 0; private double lastInputSwitchTime = 0; private Controller switchableController = null; private double askSwitchTime = 0; private ToastUtils.ControlifyToast askSwitchToast = null; public void initializeControlify() { LOGGER.info("Initializing Controlify..."); config().load(); if (!config().globalSettings().vibrationOnboarded) { minecraft.setScreen(new VibrationOnboardingScreen( minecraft.screen, answer -> this.initializeControllers() )); } else { this.initializeControllers(); } } private void initializeControllers() { DebugLog.log("Discovering and initializing controllers..."); if (config().globalSettings().loadVibrationNatives) SDL2NativesManager.initialise(); // find already connected controllers for (int jid = 0; jid <= GLFW.GLFW_JOYSTICK_LAST; jid++) { if (GLFW.glfwJoystickPresent(jid)) { var controllerOpt = Controller.createOrGet(jid, controllerHIDService.fetchType()); if (controllerOpt.isEmpty()) continue; var controller = controllerOpt.get(); LOGGER.info("Controller found: " + controller.name()); config().loadOrCreateControllerData(controller); if (config().currentControllerUid().equals(controller.uid())) setCurrentController(controller); if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) { controller.config().allowVibrations = false; config().setDirty(); } } } checkCompoundJoysticks(); if (Controller.CONTROLLERS.isEmpty()) { LOGGER.info("No controllers found."); } if (currentController() == Controller.DUMMY && config().isFirstLaunch()) { this.setCurrentController(Controller.CONTROLLERS.values().stream().findFirst().orElse(null)); } else { // setCurrentController saves config config().saveIfDirty(); } // listen for new controllers GLFW.glfwSetJoystickCallback((jid, event) -> { try { if (event == GLFW.GLFW_CONNECTED) { this.onControllerHotplugged(jid); } else if (event == GLFW.GLFW_DISCONNECTED) { this.onControllerDisconnect(jid); } } catch (Exception e) { e.printStackTrace(); } }); ClientTickEvents.START_CLIENT_TICK.register(this::tick); FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypoint -> { try { entrypoint.onControllersDiscovered(this); } catch (Exception e) { LOGGER.error("Failed to run `onControllersDiscovered` on Controlify entrypoint: " + entrypoint.getClass().getName(), e); } }); } public void preInitialiseControlify() { DebugProperties.printProperties(); LOGGER.info("Pre-initializing Controlify..."); this.inGameInputHandler = new InGameInputHandler(Controller.DUMMY); // initialize with dummy controller before connection in case of no controller this.virtualMouseHandler = new VirtualMouseHandler(); controllerHIDService = new ControllerHIDService(); controllerHIDService.start(); FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypoint -> { try { entrypoint.onControlifyPreInit(this); } catch (Exception e) { LOGGER.error("Failed to run `onControlifyPreInit` on Controlify entrypoint: " + entrypoint.getClass().getName(), e); } }); } public void tick(Minecraft client) { if (minecraft.getOverlay() == null) { if (!calibrationQueue.isEmpty()) { Screen screen = minecraft.screen; while (!calibrationQueue.isEmpty()) { screen = new ControllerDeadzoneCalibrationScreen(calibrationQueue.poll(), screen); } minecraft.setScreen(screen); } } boolean outOfFocus = !config().globalSettings().outOfFocusInput && !client.isWindowActive(); for (var controller : Controller.CONTROLLERS.values()) { if (!outOfFocus) wrapControllerError(controller::updateState, "Updating controller state", controller); else wrapControllerError(controller::clearState, "Clearing controller state", controller); } if (switchableController != null && Blaze3D.getTime() - askSwitchTime <= 10000) { if (switchableController.state().hasAnyInput()) { this.setCurrentController(switchableController); if (askSwitchToast != null) { askSwitchToast.remove(); askSwitchToast = null; } switchableController.clearState(); switchableController = null; } } wrapControllerError(() -> tickController(currentController, outOfFocus), "Ticking current controller", currentController); } private void tickController(Controller controller, boolean outOfFocus) { ControllerState state = controller.state(); if (outOfFocus) { state = ControllerState.EMPTY; controller.rumbleManager().clearEffects(); } else { controller.rumbleManager().tick(); } if (state.hasAnyInput()) this.setInputMode(InputMode.CONTROLLER); if (consecutiveInputSwitches > 100) { LOGGER.warn("Controlify detected current controller to be constantly giving input and has been disabled."); ToastUtils.sendToast( Component.translatable("controlify.toast.faulty_input.title"), Component.translatable("controlify.toast.faulty_input.description"), true ); this.setCurrentController(null); consecutiveInputSwitches = 0; return; } if (minecraft.screen != null) { ScreenProcessorProvider.provide(minecraft.screen).onControllerUpdate(controller); } if (minecraft.level != null) { this.inGameInputHandler().inputTick(); } this.virtualMouseHandler().handleControllerInput(controller); ControlifyEvents.CONTROLLER_STATE_UPDATED.invoker().onControllerStateUpdate(controller); } public static void wrapControllerError(Runnable runnable, String errorTitle, Controller controller) { try { runnable.run(); } catch (Throwable e) { CrashReport crashReport = CrashReport.forThrowable(e, errorTitle); CrashReportCategory category = crashReport.addCategory("Affected controller"); category.setDetail("Controller name", controller::name); category.setDetail("Controller identification", () -> controller.type().toString()); category.setDetail("Controller type", () -> controller.getClass().getCanonicalName()); throw new ReportedException(crashReport); } } public ControlifyConfig config() { return config; } private void onControllerHotplugged(int jid) { var controllerOpt = Controller.createOrGet(jid, controllerHIDService.fetchType()); if (controllerOpt.isEmpty()) return; var controller = controllerOpt.get(); LOGGER.info("Controller connected: " + controller.name()); config().loadOrCreateControllerData(currentController); if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) { controller.config().allowVibrations = false; config().setDirty(); } this.askToSwitchController(controller); checkCompoundJoysticks(); config().saveIfDirty(); } private void onControllerDisconnect(int jid) { Controller.CONTROLLERS.values().stream().filter(controller -> controller.joystickId() == jid).findAny().ifPresent(controller -> { Controller.remove(controller); setCurrentController(Controller.CONTROLLERS.values().stream().findFirst().orElse(null)); LOGGER.info("Controller disconnected: " + controller.name()); this.setInputMode(currentController == null ? InputMode.KEYBOARD_MOUSE : InputMode.CONTROLLER); ToastUtils.sendToast( Component.translatable("controlify.toast.controller_disconnected.title"), Component.translatable("controlify.toast.controller_disconnected.description", controller.name()), false ); }); checkCompoundJoysticks(); } private void checkCompoundJoysticks() { config().getCompoundJoysticks().values().forEach(info -> { try { if (info.isLoaded() && !info.canBeUsed()) { LOGGER.warn("Unloading compound joystick " + info.friendlyName() + " due to missing controllers."); Controller.CONTROLLERS.remove(info.type().mappingId()); } if (!info.isLoaded() && info.canBeUsed()) { LOGGER.info("Loading compound joystick " + info.type().mappingId() + "."); CompoundJoystickController controller = info.attemptCreate().orElseThrow(); Controller.CONTROLLERS.put(info.type().mappingId(), controller); config().loadOrCreateControllerData(controller); } } catch (Exception e) { e.printStackTrace(); } }); } private void askToSwitchController(Controller controller) { this.switchableController = controller; this.askSwitchTime = Blaze3D.getTime(); askSwitchToast = ToastUtils.sendToast( Component.translatable("controlify.toast.ask_to_switch.title"), Component.translatable("controlify.toast.ask_to_switch.description", controller.name()), true ); } @Override public @NotNull Controller currentController() { if (currentController == null) return Controller.DUMMY; return currentController; } public void setCurrentController(Controller controller) { if (controller == null) controller = Controller.DUMMY; if (this.currentController == controller) return; if (this.currentController != null) this.currentController.close(); this.currentController = controller; this.currentController.open(); if (switchableController == controller) { switchableController = null; } DebugLog.log("Updated current controller to {}({})", controller.name(), controller.uid()); if (!config().currentControllerUid().equals(controller.uid())) { config().save(); } this.inGameInputHandler = new InGameInputHandler(controller); if (minecraft.player != null) { this.inGameButtonGuide = new InGameButtonGuide(controller, Minecraft.getInstance().player); } if (!controller.config().calibrated && controller != Controller.DUMMY) calibrationQueue.add(controller); } public InGameInputHandler inGameInputHandler() { return inGameInputHandler; } public InGameButtonGuide inGameButtonGuide() { return inGameButtonGuide; } public VirtualMouseHandler virtualMouseHandler() { return virtualMouseHandler; } public ControllerHIDService controllerHIDService() { return controllerHIDService; } public @NotNull InputMode currentInputMode() { return currentInputMode; } @Override public void setInputMode(@NotNull InputMode currentInputMode) { if (this.currentInputMode == currentInputMode) return; this.currentInputMode = currentInputMode; var minecraft = Minecraft.getInstance(); if (!minecraft.mouseHandler.isMouseGrabbed()) hideMouse(currentInputMode == InputMode.CONTROLLER, true); if (minecraft.screen != null) { ScreenProcessorProvider.provide(minecraft.screen).onInputModeChanged(currentInputMode); } if (Minecraft.getInstance().player != null) { if (currentInputMode == InputMode.KEYBOARD_MOUSE) this.inGameButtonGuide = null; else this.inGameButtonGuide = new InGameButtonGuide(this.currentController != null ? currentController : Controller.DUMMY, Minecraft.getInstance().player); } if (Blaze3D.getTime() - lastInputSwitchTime < 20) { consecutiveInputSwitches++; } else { consecutiveInputSwitches = 0; } lastInputSwitchTime = Blaze3D.getTime(); ControlifyEvents.INPUT_MODE_CHANGED.invoker().onInputModeChanged(currentInputMode); } public void hideMouse(boolean hide, boolean moveMouse) { var minecraft = Minecraft.getInstance(); GLFW.glfwSetInputMode( minecraft.getWindow().getWindow(), GLFW.GLFW_CURSOR, hide ? GLFW.GLFW_CURSOR_HIDDEN : GLFW.GLFW_CURSOR_NORMAL ); if (minecraft.screen != null) { var mouseHandlerAccessor = (MouseHandlerAccessor) minecraft.mouseHandler; if (hide && !virtualMouseHandler().isVirtualMouseEnabled() && moveMouse) { // stop mouse hovering over last element before hiding cursor but don't actually move it // so when the user switches back to mouse it will be in the same place mouseHandlerAccessor.invokeOnMove(minecraft.getWindow().getWindow(), -50, -50); } } } public static Controlify instance() { if (instance == null) instance = new Controlify(); return instance; } }