diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 8ffd630..44d914c 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -56,6 +56,7 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; import static io.github.libsdl4j.api.gamecontroller.SdlGamecontroller.SDL_GameControllerAddMappingsFromRW; +import static io.github.libsdl4j.api.gamecontroller.SdlGamecontroller.SDL_GameControllerNumMappings; import static io.github.libsdl4j.api.rwops.SdlRWops.SDL_RWFromConstMem; public class Controlify implements ControlifyApi { @@ -426,6 +427,8 @@ public class Controlify implements ControlifyApi { * @param resource the already located `gamecontrollerdb.txt` resource */ private void loadGamepadMappings(Resource resource) { + Log.LOGGER.debug("Loading gamepad mappings..."); + try (InputStream is = resource.open()) { byte[] bytes = ByteStreams.toByteArray(is); diff --git a/src/main/java/dev/isxander/controlify/config/GlobalSettings.java b/src/main/java/dev/isxander/controlify/config/GlobalSettings.java index 5e46401..2ee16e2 100644 --- a/src/main/java/dev/isxander/controlify/config/GlobalSettings.java +++ b/src/main/java/dev/isxander/controlify/config/GlobalSettings.java @@ -19,11 +19,11 @@ public class GlobalSettings { AbstractContainerScreen.class ); - @SerializedName("keyboardMovement") - public boolean alwaysKeyboardMovement = false; + @SerializedName("keyboardMovement") public boolean alwaysKeyboardMovement = false; public List keyboardMovementWhitelist = new ArrayList<>(); public boolean outOfFocusInput = false; public boolean loadVibrationNatives = false; + public String customVibrationNativesPath = ""; public boolean vibrationOnboarded = false; public ReachAroundMode reachAround = ReachAroundMode.OFF; public boolean allowServerRumble = true; diff --git a/src/main/java/dev/isxander/controlify/driver/RumbleDriver.java b/src/main/java/dev/isxander/controlify/driver/RumbleDriver.java index 0adaf0b..2c4102f 100644 --- a/src/main/java/dev/isxander/controlify/driver/RumbleDriver.java +++ b/src/main/java/dev/isxander/controlify/driver/RumbleDriver.java @@ -5,6 +5,10 @@ public interface RumbleDriver extends Driver { boolean isRumbleSupported(); + boolean rumbleTrigger(float left, float right); + + boolean isTriggerRumbleSupported(); + String getRumbleDetails(); RumbleDriver UNSUPPORTED = new RumbleDriver() { @@ -22,6 +26,16 @@ public interface RumbleDriver extends Driver { return false; } + @Override + public boolean rumbleTrigger(float left, float right) { + return false; + } + + @Override + public boolean isTriggerRumbleSupported() { + return false; + } + @Override public String getRumbleDetails() { return "Unsupported"; diff --git a/src/main/java/dev/isxander/controlify/driver/SDL2JoystickDriver.java b/src/main/java/dev/isxander/controlify/driver/SDL2JoystickDriver.java index 88f69ef..c7bcd77 100644 --- a/src/main/java/dev/isxander/controlify/driver/SDL2JoystickDriver.java +++ b/src/main/java/dev/isxander/controlify/driver/SDL2JoystickDriver.java @@ -8,11 +8,12 @@ import static io.github.libsdl4j.api.joystick.SdlJoystick.*; public class SDL2JoystickDriver implements RumbleDriver { private final SDL_Joystick ptrJoystick; - private final boolean isRumbleSupported; + private final boolean isRumbleSupported, isTriggerRumbleSupported; public SDL2JoystickDriver(int jid) { this.ptrJoystick = SDL_JoystickOpen(jid); this.isRumbleSupported = SDL_JoystickHasRumble(ptrJoystick); + this.isTriggerRumbleSupported = SDL_JoystickHasRumbleTriggers(ptrJoystick); } @Override @@ -23,21 +24,36 @@ public class SDL2JoystickDriver implements RumbleDriver { @Override public boolean rumble(float strongMagnitude, float weakMagnitude) { // duration of 0 is infinite - if (SDL_JoystickRumble(ptrJoystick, (short)(strongMagnitude * 65535.0F), (short)(weakMagnitude * 65535.0F), 0) != 0) { + if (SDL_JoystickRumble(ptrJoystick, (short)(strongMagnitude * 0xFFFF), (short)(weakMagnitude * 0xFFFF), 0) != 0) { Log.LOGGER.error("Could not rumble controller: " + SDL_GetError()); return false; } return true; } + @Override + public boolean rumbleTrigger(float left, float right) { + // duration of 0 is infinite + if (SDL_JoystickRumbleTriggers(ptrJoystick, (short)(left * 0xFFFF), (short)(right * 0xFFFF), 0) != 0) { + Log.LOGGER.error("Could not rumble controller trigger: " + SDL_GetError()); + return false; + } + return true; + } + @Override public boolean isRumbleSupported() { return isRumbleSupported; } + @Override + public boolean isTriggerRumbleSupported() { + return isTriggerRumbleSupported; + } + @Override public String getRumbleDetails() { - return "SDL2joy supported=" + isRumbleSupported(); + return "SDL2joy supported=" + isRumbleSupported() + " trigger=" + isTriggerRumbleSupported(); } @Override diff --git a/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java b/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java index cf66842..10af2fe 100644 --- a/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java +++ b/src/main/java/dev/isxander/controlify/driver/SDL2NativesManager.java @@ -1,8 +1,8 @@ package dev.isxander.controlify.driver; import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.config.ControlifyConfig; import dev.isxander.controlify.gui.screen.DownloadingSDLScreen; -import dev.isxander.controlify.utils.DebugLog; import dev.isxander.controlify.utils.Log; import dev.isxander.controlify.utils.TrackingBodySubscriber; import dev.isxander.controlify.utils.TrackingConsumer; @@ -10,22 +10,12 @@ import io.github.libsdl4j.jna.SdlNativeLibraryLoader; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.Util; import net.minecraft.client.Minecraft; -import org.apache.commons.lang3.Validate; -import java.io.File; -import java.io.FileOutputStream; import java.net.URI; -import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.channels.Channels; -import java.nio.channels.FileChannel; -import java.nio.channels.ReadableByteChannel; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.Comparator; +import java.nio.file.*; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; @@ -46,7 +36,6 @@ public class SDL2NativesManager { new Target(Util.OS.OSX, true, true), new NativeFileInfo("darwin-aarch64", "macos-aarch64", "dylib") ); private static final String NATIVE_LIBRARY_URL = "https://maven.isxander.dev/releases/dev/isxander/libsdl4j-natives/%s/".formatted(SDL2_VERSION); - private static final Path NATIVES_PATH = FabricLoader.getInstance().getGameDir().resolve("controlify-natives"); private static boolean loaded = false; private static boolean attemptedLoad = false; @@ -65,7 +54,7 @@ public class SDL2NativesManager { attemptedLoad = true; - Path localLibraryPath = NATIVES_PATH.resolve(Target.CURRENT.getArtifactName()); + Path localLibraryPath = getNativesFolderPath().resolve(Target.CURRENT.getArtifactName()); if (Files.exists(localLibraryPath)) { boolean success = loadAndStart(localLibraryPath); @@ -117,7 +106,7 @@ public class SDL2NativesManager { } private static CompletableFuture downloadAndStart(Path localLibraryPath) { - return downloadLibrary() + return downloadLibrary(localLibraryPath) .thenCompose(success -> { if (!success) { return CompletableFuture.completedFuture(false); @@ -128,9 +117,7 @@ public class SDL2NativesManager { .thenCompose(success -> Minecraft.getInstance().submit(() -> success)); } - private static CompletableFuture downloadLibrary() { - Path path = NATIVES_PATH.resolve(Target.CURRENT.getArtifactName()); - + private static CompletableFuture downloadLibrary(Path path) { try { Files.deleteIfExists(path); Files.createDirectories(path.getParent()); @@ -191,6 +178,22 @@ public class SDL2NativesManager { return attemptedLoad; } + private static Path getNativesFolderPath() { + Path nativesFolderPath = FabricLoader.getInstance().getGameDir(); + ControlifyConfig config = Controlify.instance().config(); + String customPath = config.globalSettings().customVibrationNativesPath; + if (!customPath.isEmpty()) { + try { + nativesFolderPath = Path.of(customPath); + } catch (InvalidPathException e) { + Log.LOGGER.error("Invalid custom SDL2 native library path. Using default and resetting custom path.", e); + config.globalSettings().customVibrationNativesPath = ""; + config.save(); + } + } + return nativesFolderPath.resolve("controlify-natives"); + } + public record Target(Util.OS os, boolean is64Bit, boolean isARM) { public static final Target CURRENT = Util.make(() -> { Util.OS os = Util.getPlatform(); diff --git a/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java b/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java index 221f8c0..48b627a 100644 --- a/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java +++ b/src/main/java/dev/isxander/controlify/driver/gamepad/SDL2GamepadDriver.java @@ -21,7 +21,7 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R private final SDL_GameController ptrGamepad; private BasicGamepadState state = BasicGamepadState.EMPTY; private GamepadState.GyroState gyroDelta = new GamepadState.GyroState(0, 0, 0); - private final boolean isGyroSupported, isRumbleSupported; + private final boolean isGyroSupported, isRumbleSupported, isTriggerRumbleSupported; private final String guid; public SDL2GamepadDriver(int jid) { @@ -29,6 +29,7 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R this.guid = SDL_JoystickGetGUID(SDL_GameControllerGetJoystick(ptrGamepad)).toString(); this.isGyroSupported = SDL_GameControllerHasSensor(ptrGamepad, SDL_SENSOR_GYRO); this.isRumbleSupported = SDL_GameControllerHasRumble(ptrGamepad); + this.isTriggerRumbleSupported = SDL_GameControllerHasRumbleTriggers(ptrGamepad); if (this.isGyroSupported()) { SDL_GameControllerSetSensorEnabled(ptrGamepad, SDL_SENSOR_GYRO, true); @@ -94,14 +95,28 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R @Override public boolean rumble(float strongMagnitude, float weakMagnitude) { + if (!isRumbleSupported()) return false; + // duration of 0 is infinite - if (SDL_GameControllerRumble(ptrGamepad, (short)(strongMagnitude * 65535.0F), (short)(weakMagnitude * 65535.0F), 0) != 0) { + if (SDL_GameControllerRumble(ptrGamepad, (short)(strongMagnitude * 0xFFFF), (short)(weakMagnitude * 0xFFFF), 0) != 0) { Log.LOGGER.error("Could not rumble controller: " + SDL_GetError()); return false; } return true; } + @Override + public boolean rumbleTrigger(float left, float right) { + if (!isTriggerRumbleSupported()) return false; + + // duration of 0 is infinite + if (SDL_GameControllerRumbleTriggers(ptrGamepad, (short)(left * 0xFFFF), (short)(right * 0xFFFF), 0) != 0) { + Log.LOGGER.error("Could not rumble controller trigger: " + SDL_GetError()); + return false; + } + return true; + } + @Override public GamepadState.GyroStateC getGyroState() { return gyroDelta; @@ -132,6 +147,11 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R return isRumbleSupported; } + @Override + public boolean isTriggerRumbleSupported() { + return isTriggerRumbleSupported; + } + @Override public String getGUID() { return guid; @@ -149,7 +169,7 @@ public class SDL2GamepadDriver implements BasicGamepadInputDriver, GyroDriver, R @Override public String getRumbleDetails() { - return "SDL2gp supported=" + isRumbleSupported(); + return "SDL2gp supported=" + isRumbleSupported() + " trigger=" + isTriggerRumbleSupported(); } @Override diff --git a/src/main/java/dev/isxander/controlify/gui/controllers/FormattableStringController.java b/src/main/java/dev/isxander/controlify/gui/controllers/FormattableStringController.java new file mode 100644 index 0000000..27cc09a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/gui/controllers/FormattableStringController.java @@ -0,0 +1,26 @@ +package dev.isxander.controlify.gui.controllers; + +import dev.isxander.yacl3.api.Option; +import dev.isxander.yacl3.api.controller.ValueFormatter; +import dev.isxander.yacl3.gui.controllers.string.StringController; +import net.minecraft.network.chat.Component; + +public class FormattableStringController extends StringController { + private final ValueFormatter formatter; + + /** + * Constructs a string controller + * + * @param option bound option + * @param formatter the formatter to use + */ + public FormattableStringController(Option option, ValueFormatter formatter) { + super(option); + this.formatter = formatter; + } + + @Override + public Component formatValue() { + return formatter.format(getString()); + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java b/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java index 5820490..4c8afb2 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java +++ b/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java @@ -3,6 +3,7 @@ package dev.isxander.controlify.gui.screen; import dev.isxander.controlify.Controlify; import dev.isxander.controlify.api.ControlifyApi; import dev.isxander.controlify.config.GlobalSettings; +import dev.isxander.controlify.gui.controllers.FormattableStringController; import dev.isxander.controlify.reacharound.ReachAroundMode; import dev.isxander.controlify.server.ServerPolicies; import dev.isxander.controlify.server.ServerPolicy; @@ -27,20 +28,36 @@ public class GlobalSettingsScreenFactory { .title(Component.translatable("controlify.gui.global_settings.title")) .category(ConfigCategory.createBuilder() .name(Component.translatable("controlify.gui.global_settings.title")) - .option(Option.createBuilder() - .name(Component.translatable("controlify.gui.load_vibration_natives")) - .description(OptionDescription.createBuilder() - .text(Component.translatable("controlify.gui.load_vibration_natives.tooltip")) - .text(Component.translatable("controlify.gui.load_vibration_natives.tooltip.warning").withStyle(ChatFormatting.RED)) - .build()) - .binding(true, () -> globalSettings.loadVibrationNatives, v -> globalSettings.loadVibrationNatives = v) - .controller(opt -> BooleanControllerBuilder.create(opt).yesNoFormatter()) - .flag(OptionFlag.GAME_RESTART) - .build()) .option(ButtonOption.createBuilder() .name(Component.translatable("controlify.gui.open_issue_tracker")) .action((screen, button) -> Util.getPlatform().openUri("https://github.com/isxander/controlify/issues")) .build()) + .group(OptionGroup.createBuilder() + .name(Component.translatable("controlify.gui.natives")) + .option(Option.createBuilder() + .name(Component.translatable("controlify.gui.load_vibration_natives")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.load_vibration_natives.tooltip")) + .text(Component.translatable("controlify.gui.load_vibration_natives.tooltip.warning").withStyle(ChatFormatting.RED)) + .build()) + .binding(true, () -> globalSettings.loadVibrationNatives, v -> globalSettings.loadVibrationNatives = v) + .controller(opt -> BooleanControllerBuilder.create(opt).yesNoFormatter()) + .flag(OptionFlag.GAME_RESTART) + .build()) + .option(Option.createBuilder() + .name(Component.translatable("controlify.gui.custom_natives_path")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.custom_natives_path.tooltip")) + .text(Component.translatable("controlify.gui.custom_natives_path.tooltip.warning").withStyle(ChatFormatting.RED)) + .build()) + .binding("", () -> globalSettings.customVibrationNativesPath, v -> globalSettings.customVibrationNativesPath = v) + .customController(opt -> new FormattableStringController(opt, s -> { + if (s.isEmpty()) + return Component.translatable("controlify.gui.custom_natives_path.none"); + return Component.literal(s); + })) + .build()) + .build()) .group(OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.server_options")) .option(Option.createBuilder() diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index 0b4cee8..3966b75 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -7,11 +7,16 @@ "controlify.gui.carousel.art_credit": "Controller art by %s.", "controlify.gui.global_settings.title": "Global Settings", - "controlify.gui.server_options": "Server Options", - "controlify.gui.miscellaneous": "Miscellaneous", + "controlify.gui.natives": "Natives", "controlify.gui.load_vibration_natives": "Load Natives", "controlify.gui.load_vibration_natives.tooltip": "If enabled, Controlify will download and load native libraries on launch to enable support for enhanced features such as vibration and gyro. The download process only happens once and only downloads for your specific OS. Disabling this will not delete the natives, it just won't load them.", "controlify.gui.load_vibration_natives.tooltip.warning": "You must enable vibration support per-controller as well as this setting.", + "controlify.gui.custom_natives_path": "Custom Natives Path", + "controlify.gui.custom_natives_path.tooltip": "Specify a custom folder where Controlify will save and load it's native libraries. This is an absolute path and is not relative to .minecraft. If you enter an invalid directory, this will be reset. Leave blank for default.", + "controlify.gui.custom_natives_path.tooltip.warning": "This is an advanced setting. Don't touch it if you don't know what you're doing!", + "controlify.gui.custom_natives_path.none": "Not set", + "controlify.gui.server_options": "Server Options", + "controlify.gui.miscellaneous": "Miscellaneous", "controlify.gui.reach_around": "Block Reach Around", "controlify.gui.reach_around.tooltip": "If enabled, you can interact with the block you are standing on in the direction you are looking.", "controlify.gui.reach_around.tooltip.parity": "This is parity with bedrock edition where you can also do this.",