From ebbc549e3200684f1113ee262acd5f32f44062e6 Mon Sep 17 00:00:00 2001 From: Xander Date: Tue, 4 Apr 2023 17:17:01 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AE=F0=9F=93=B3=20Controller=20Vibrati?= =?UTF-8?q?on!=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 6 + comparison.md | 4 +- gradle/libs.versions.toml | 2 + .../dev/isxander/controlify/Controlify.java | 57 ++++++-- .../api/buttonguide/ButtonRenderPosition.java | 4 +- .../controlify/bindings/JoystickHatBind.java | 4 +- .../controlify/config/GlobalSettings.java | 2 + .../controlify/config/gui/YACLHelper.java | 45 +++++- .../controller/AbstractController.java | 47 +++++- .../controlify/controller/Controller.java | 23 ++- .../controller/ControllerConfig.java | 4 +- .../controller/hid/ControllerHIDService.java | 4 +- .../joystick/CompoundJoystickController.java | 23 ++- .../controller/sdl2/SDL2NativesManager.java | 137 ++++++++++++++++++ .../gui/screen/VibrationOnboardingScreen.java | 25 ++++ .../controlify/ingame/InGameInputHandler.java | 15 +- .../fapi/KeyBindingRegistryImplAccessor.java | 2 +- .../mixins/core/MinecraftMixin.java | 4 +- .../rumble/damage/LocalPlayerMixin.java | 31 ++++ .../rumble/sounds/LevelRendererMixin.java | 46 ++++++ .../rumble/useitem/LivingEntityMixin.java | 23 +++ .../rumble/useitem/LocalPlayerMixin.java | 29 ++++ .../controlify/rumble/RumbleCapable.java | 7 + .../controlify/rumble/RumbleEffect.java | 77 ++++++++++ .../controlify/rumble/RumbleManager.java | 46 ++++++ .../controlify/rumble/RumbleState.java | 4 + .../controller_identification.json5 | 1 + .../assets/controlify/lang/en_us.json | 14 +- src/main/resources/controlify.mixins.json | 6 +- .../controlify/test/ClientTestHelper.java | 9 ++ .../test/ControlifyAutoTestClient.java | 3 + .../controlify/test/FakeController.java | 24 +++ 32 files changed, 686 insertions(+), 42 deletions(-) create mode 100644 src/main/java/dev/isxander/controlify/controller/sdl2/SDL2NativesManager.java create mode 100644 src/main/java/dev/isxander/controlify/gui/screen/VibrationOnboardingScreen.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/LocalPlayerMixin.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/rumble/sounds/LevelRendererMixin.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LivingEntityMixin.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java create mode 100644 src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java create mode 100644 src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java create mode 100644 src/main/java/dev/isxander/controlify/rumble/RumbleManager.java create mode 100644 src/main/java/dev/isxander/controlify/rumble/RumbleState.java diff --git a/build.gradle.kts b/build.gradle.kts index 1b0b6ab..2b7301c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,7 @@ group = "dev.isxander" version = "1.1.0+1.19.4" repositories { + mavenLocal() mavenCentral() maven("https://maven.terraformersmc.com") maven("https://maven.isxander.dev/releases") @@ -30,6 +31,7 @@ repositories { } } maven("https://jitpack.io") + mavenLocal() maven("https://maven.flashyreese.me/snapshots") } @@ -88,6 +90,10 @@ dependencies { implementation(libs.hid4java) include(libs.hid4java) + // controller rumble + implementation(libs.sdl2.jni) + include(libs.sdl2.jni) + // used to parse hiddb.json5 implementation(libs.quilt.json5) include(libs.quilt.json5) diff --git a/comparison.md b/comparison.md index 6df2562..281aa4d 100644 --- a/comparison.md +++ b/comparison.md @@ -10,9 +10,9 @@ | **Controller button rendering** | Powered by resource pack controller detection | Texture atlas for hardcoded identifiers | | **In-game look sensitivity & behaviour** | Emulated Bedrock Edition with good defaults and snappy behaviour | testing required - reported bad defaults | | **Container interaction** | Controlled cursor snaps to container slots (with API) with power of left click, right click and shift click | No slot snapping, testing required | -| **Touchscreen support** | ⛔ | ✅ | +| **Touchscreen support** | ⛔ | ✅ No multi-touch support | | **Joystick support** | ✅ Multiple joysticks can be combined together (no UI yet) | ✅ Multiple joysticks can be combined together | | **Joystick mapping** | Powered by resource packs, unlimited amount of buttons available | All combined joysticks limited to gamepad input. Powered by SDL mappings | | **Configurability** | Each controller has individual settings with the ability to map every single controller action. No hardcoding! | needs testing | | **Mod keybindings** | All keybinds work out of box after mapping buttons | All keybinds work out of box after mapping buttons | -| | | | +| **Controller rumble** | ✅ | ⛔ | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5e17eb..2437501 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ quilt_json5 = "1.0.3" sodium = "mc1.19.4-0.4.10" iris = "1.5.2+1.19.4" immediately_fast = "1.1.10+1.19.4" +sdl2_jni = "1.0.0" [libraries] minecraft = { module = "com.mojang:minecraft", version.ref = "minecraft" } @@ -33,6 +34,7 @@ quilt_json5 = { module = "org.quiltmc:quilt-json5", version.ref = "quilt_json5" sodium = { module = "maven.modrinth:sodium", version.ref = "sodium" } iris = { module = "maven.modrinth:iris", version.ref = "iris" } immediately_fast = { module = "maven.modrinth:immediatelyfast", version.ref = "immediately_fast" } +sdl2_jni = { module = "dev.isxander:sdl2-jni", version.ref = "sdl2_jni" } test_fabric_loader = { module = "net.fabricmc:fabric-loader-junit", version.ref = "fabric_loader" } diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index db081e0..8a80b77 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -7,7 +7,9 @@ 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.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; @@ -53,27 +55,56 @@ public class Controlify implements ControlifyApi { private double askSwitchTime = 0; private ToastUtils.ControlifyToast askSwitchToast = null; - public void initializeControllers() { + 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() { LOGGER.info("Discovering and initializing controllers..."); config().load(); - controllerHIDService = new ControllerHIDService(); - controllerHIDService.start(); + if (config().globalSettings().loadVibrationNatives) + SDL2NativesManager.initialise(); + boolean dirtyControllerConfig = false; // find already connected controllers for (int jid = 0; jid <= GLFW.GLFW_JOYSTICK_LAST; jid++) { if (GLFW.glfwJoystickPresent(jid)) { - var controller = Controller.createOrGet(jid, controllerHIDService.fetchType()); - LOGGER.info("Controller found: " + controller.name()); + try { + var controller = Controller.createOrGet(jid, controllerHIDService.fetchType()); + LOGGER.info("Controller found: " + controller.name()); - config().loadOrCreateControllerData(controller); + config().loadOrCreateControllerData(controller); - if (config().currentControllerUid().equals(controller.uid())) - setCurrentController(controller); + if (config().currentControllerUid().equals(controller.uid())) + setCurrentController(controller); + + if (controller.config().allowVibrations && !config().globalSettings().loadVibrationNatives) { + controller.config().allowVibrations = false; + dirtyControllerConfig = true; + } + } catch (Exception e) { + LOGGER.error("Failed to initialize controller with jid " + jid, e); + } } } + if (dirtyControllerConfig) { + config().save(); + } + checkCompoundJoysticks(); if (Controller.CONTROLLERS.isEmpty()) { @@ -108,12 +139,15 @@ public class Controlify implements ControlifyApi { }); } - public void initializeControlify() { + public void preInitialiseControlify() { 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); @@ -139,8 +173,11 @@ public class Controlify implements ControlifyApi { for (var controller : Controller.CONTROLLERS.values()) { if (!outOfFocus) controller.updateState(); - else + else { controller.clearState(); + controller.rumbleManager().stopCurrentEffect(); + } + controller.rumbleManager().tick(); } ControllerState state = currentController == null ? ControllerState.EMPTY : currentController.state(); diff --git a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonRenderPosition.java b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonRenderPosition.java index 2f11374..57de2e2 100644 --- a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonRenderPosition.java +++ b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonRenderPosition.java @@ -5,11 +5,11 @@ package dev.isxander.controlify.api.buttonguide; */ public enum ButtonRenderPosition { /** - * Renders outside the button the left. + * Renders outside the button the left edge of the screen. */ LEFT, /** - * Renders outside the button the right. + * Renders outside the button the right edge of the screen. */ RIGHT, /** diff --git a/src/main/java/dev/isxander/controlify/bindings/JoystickHatBind.java b/src/main/java/dev/isxander/controlify/bindings/JoystickHatBind.java index d635a82..08d7ccb 100644 --- a/src/main/java/dev/isxander/controlify/bindings/JoystickHatBind.java +++ b/src/main/java/dev/isxander/controlify/bindings/JoystickHatBind.java @@ -40,9 +40,9 @@ public class JoystickHatBind implements IBind { else if (hatState.isDown()) direction = "down"; else if (hatState.isLeft()) - direction = "left"; + direction = "strong"; else if (hatState.isRight()) - direction = "right"; + direction = "weak"; var texture = new ResourceLocation("controlify", "textures/gui/joystick/" + type + "/hat" + button + "_" + direction + ".png"); diff --git a/src/main/java/dev/isxander/controlify/config/GlobalSettings.java b/src/main/java/dev/isxander/controlify/config/GlobalSettings.java index 38849c5..21c7d94 100644 --- a/src/main/java/dev/isxander/controlify/config/GlobalSettings.java +++ b/src/main/java/dev/isxander/controlify/config/GlobalSettings.java @@ -15,5 +15,7 @@ public class GlobalSettings { public boolean keyboardMovement = false; public boolean outOfFocusInput = false; + public boolean loadVibrationNatives = false; + public boolean vibrationOnboarded = false; public ReachAroundMode reachAround = ReachAroundMode.OFF; } 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 3d5201e..aad75b8 100644 --- a/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java +++ b/src/main/java/dev/isxander/controlify/config/gui/YACLHelper.java @@ -15,6 +15,8 @@ import dev.isxander.controlify.controller.joystick.SingleJoystickController; import dev.isxander.controlify.controller.joystick.JoystickState; import dev.isxander.controlify.gui.screen.ControllerDeadzoneCalibrationScreen; import dev.isxander.controlify.reacharound.ReachAroundMode; +import dev.isxander.controlify.rumble.RumbleEffect; +import dev.isxander.controlify.rumble.RumbleState; import dev.isxander.yacl.api.*; import dev.isxander.yacl.gui.controllers.ActionController; import dev.isxander.yacl.gui.controllers.BooleanController; @@ -46,6 +48,8 @@ public class YACLHelper { .title(Component.literal("Controlify")) .save(() -> controlify.config().save()); + Option globalVibrationOption; + var globalSettings = Controlify.instance().config().globalSettings(); var globalCategory = ConfigCategory.createBuilder() .name(Component.translatable("controlify.gui.category.global")) @@ -55,6 +59,14 @@ public class YACLHelper { .binding(Controlify.instance().currentController(), () -> Controlify.instance().currentController(), v -> Controlify.instance().setCurrentController(v)) .controller(opt -> new CyclingListController<>(opt, Iterables.concat(List.of(Controller.DUMMY), Controller.CONTROLLERS.values().stream().filter(Controller::canBeUsed).toList()), c -> Component.literal(c == Controller.DUMMY ? "Disabled" : c.name()))) .build()) + .option(globalVibrationOption = Option.createBuilder(boolean.class) + .name(Component.translatable("controlify.gui.load_vibration_natives")) + .tooltip(Component.translatable("controlify.gui.load_vibration_natives.tooltip")) + .tooltip(Component.translatable("controlify.gui.load_vibration_natives.tooltip.warning").withStyle(ChatFormatting.RED)) + .binding(GlobalSettings.DEFAULT.loadVibrationNatives, () -> globalSettings.loadVibrationNatives, v -> globalSettings.loadVibrationNatives = v) + .controller(opt -> new BooleanController(opt, BooleanController.YES_NO_FORMATTER, false)) + .flag(OptionFlag.GAME_RESTART) + .build()) .option(Option.createBuilder(ReachAroundMode.class) .name(Component.translatable("controlify.gui.reach_around")) .tooltip(Component.translatable("controlify.gui.reach_around.tooltip")) @@ -84,13 +96,13 @@ public class YACLHelper { yacl.category(globalCategory.build()); for (var controller : Controller.CONTROLLERS.values()) { - yacl.category(createControllerCategory(controller)); + yacl.category(createControllerCategory(controller, globalVibrationOption)); } return yacl.build().generateScreen(parent); } - private static ConfigCategory createControllerCategory(Controller controller) { + private static ConfigCategory createControllerCategory(Controller controller, Option globalVibrationOption) { if (!controller.canBeUsed()) { return PlaceholderCategory.createBuilder() .name(Component.literal(controller.name())) @@ -141,6 +153,13 @@ public class YACLHelper { .binding(def.autoJump, () -> config.autoJump, v -> config.autoJump = v) .controller(BooleanController::new) .build()) + .option(Option.createBuilder(boolean.class) + .name(Component.translatable("controlify.gui.allow_vibrations")) + .tooltip(Component.translatable("controlify.gui.allow_vibrations.tooltip")) + .binding(globalVibrationOption.pendingValue(), () -> config.allowVibrations && globalVibrationOption.pendingValue(), v -> config.allowVibrations = v) + .available(globalVibrationOption.pendingValue()) + .controller(TickBoxController::new) + .build()) .option(Option.createBuilder(boolean.class) .name(Component.translatable("controlify.gui.show_ingame_guide")) .tooltip(Component.translatable("controlify.gui.show_ingame_guide.tooltip")) @@ -166,9 +185,9 @@ public class YACLHelper { .controller(opt -> new FloatSliderController(opt, 0f, 0.8f, 0.1f, percentFormatter)) .build()) .option(Option.createBuilder(boolean.class) - .name(Component.translatable("controlify.gui.reduce_bow_sensitivity")) - .tooltip(Component.translatable("controlify.gui.reduce_bow_sensitivity.tooltip")) - .binding(def.reduceBowSensitivity, () -> config.reduceBowSensitivity, v -> config.reduceBowSensitivity = v) + .name(Component.translatable("controlify.gui.reduce_aiming_sensitivity")) + .tooltip(Component.translatable("controlify.gui.reduce_aiming_sensitivity.tooltip")) + .binding(def.reduceAimingSensitivity, () -> config.reduceAimingSensitivity, v -> config.reduceAimingSensitivity = v) .controller(TickBoxController::new) .build()); @@ -268,7 +287,21 @@ public class YACLHelper { .tooltip(Component.translatable("controlify.gui.button_activation_threshold.tooltip")) .binding(def.buttonActivationThreshold, () -> config.buttonActivationThreshold, v -> config.buttonActivationThreshold = v) .controller(opt -> new FloatSliderController(opt, 0, 1, 0.05f, v -> Component.literal(String.format("%.0f%%", v*100)))) - .build()); + .build()) + .option(ButtonOption.createBuilder() + .name(Component.translatable("controlify.gui.test_vibration")) + .tooltip(Component.translatable("controlify.gui.test_vibration.tooltip")) + .controller(ActionController::new) + .action((screen, btn) -> { + controller.rumbleManager().play( + RumbleEffect.byTime(t -> new RumbleState(0f, t), 20) + .join(RumbleEffect.byTime(t -> new RumbleState(0f, 1 - t), 20)) + .repeat(3) + .join(RumbleEffect.constant(1f, 0f, 5).join(RumbleEffect.constant(0f, 1f, 5)).repeat(10)) + ); + }) + .build());; + category.group(advancedGroup.build()); var controlsGroup = OptionGroup.createBuilder() diff --git a/src/main/java/dev/isxander/controlify/controller/AbstractController.java b/src/main/java/dev/isxander/controlify/controller/AbstractController.java index 17f70ba..3bcd868 100644 --- a/src/main/java/dev/isxander/controlify/controller/AbstractController.java +++ b/src/main/java/dev/isxander/controlify/controller/AbstractController.java @@ -6,17 +6,23 @@ import com.google.gson.JsonElement; import dev.isxander.controlify.Controlify; import dev.isxander.controlify.bindings.ControllerBindings; import dev.isxander.controlify.controller.hid.ControllerHIDService; +import dev.isxander.controlify.controller.sdl2.SDL2NativesManager; +import dev.isxander.controlify.rumble.RumbleCapable; +import dev.isxander.controlify.rumble.RumbleManager; +import org.libsdl.SDL; import org.lwjgl.glfw.GLFW; import java.util.Objects; import java.util.UUID; -public abstract class AbstractController implements Controller { +public abstract class AbstractController implements Controller, RumbleCapable { protected final int joystickId; protected String name; private final String uid; private final String guid; private final ControllerType type; + private final long ptrJoystick; + private final RumbleManager rumbleManager; private final ControllerBindings bindings; protected C config, defaultConfig; @@ -30,12 +36,16 @@ public abstract class AbstractController 1000) throw new IllegalStateException("Could not find a unique name for controller " + name + " (" + uid() + ")! (tried " + i + " times)"); } this.name = uniqueName; } @@ -105,6 +116,34 @@ public abstract class AbstractController { String uid(); @@ -33,15 +35,22 @@ public interface Controller> CONTROLLERS = new HashMap<>(); static Controller createOrGet(int joystickId, ControllerHIDService.ControllerHIDInfo hidInfo) { - if (CONTROLLERS.containsKey(hidInfo.createControllerUID())) { - return CONTROLLERS.get(hidInfo.createControllerUID()); + Optional uid = hidInfo.createControllerUID(); + if (uid.isPresent() && CONTROLLERS.containsKey(uid.get())) { + return CONTROLLERS.get(uid.get()); } if (GLFW.glfwJoystickIsGamepad(joystickId) && !DebugProperties.FORCE_JOYSTICK) { @@ -55,6 +64,11 @@ public interface Controller controller) { + CONTROLLERS.remove(controller.uid()); + controller.close(); + } + Controller DUMMY = new Controller<>() { private final ControllerBindings bindings = new ControllerBindings<>(this); private final ControllerConfig config = new ControllerConfig() { @@ -133,5 +147,10 @@ public interface Controller path) { - public String createControllerUID() { - return UUID.nameUUIDFromBytes(path().get().getBytes()).toString(); + public Optional createControllerUID() { + return path.map(p -> UUID.nameUUIDFromBytes(p.getBytes())).map(UUID::toString); } } } diff --git a/src/main/java/dev/isxander/controlify/controller/joystick/CompoundJoystickController.java b/src/main/java/dev/isxander/controlify/controller/joystick/CompoundJoystickController.java index 02caeb2..77601b8 100644 --- a/src/main/java/dev/isxander/controlify/controller/joystick/CompoundJoystickController.java +++ b/src/main/java/dev/isxander/controlify/controller/joystick/CompoundJoystickController.java @@ -8,11 +8,13 @@ import dev.isxander.controlify.bindings.ControllerBindings; import dev.isxander.controlify.controller.ControllerType; import dev.isxander.controlify.controller.joystick.mapping.JoystickMapping; import dev.isxander.controlify.controller.joystick.mapping.RPJoystickMapping; +import dev.isxander.controlify.rumble.RumbleCapable; +import dev.isxander.controlify.rumble.RumbleManager; import org.lwjgl.glfw.GLFW; import java.util.List; -public class CompoundJoystickController implements JoystickController { +public class CompoundJoystickController implements JoystickController, RumbleCapable { private final String uid; private final List joysticks; private final int axisCount, buttonCount, hatCount; @@ -23,6 +25,8 @@ public class CompoundJoystickController implements JoystickController joystickIds, String uid, ControllerType compoundType) { @@ -39,6 +43,8 @@ public class CompoundJoystickController implements JoystickController(this); } @@ -133,6 +139,21 @@ public class CompoundJoystickController implements JoystickController NATIVE_LIBRARIES = Map.of( + new Target(Util.OS.WINDOWS, true), "windows64/sdl2gdx64.dll", + new Target(Util.OS.WINDOWS, false), "windows32/sdl2gdx.dll", + new Target(Util.OS.LINUX, true), "linux64/libsdl2gdx64.so", + new Target(Util.OS.OSX, true), "macosx64/libsdl2gdx64.dylib" + ); + private static final String NATIVE_LIBRARY_URL = "https://raw.githubusercontent.com/isXander/sdl2-jni/master/libs/"; + + private static Path osNativePath; + private static boolean loaded = false; + + public static void initialise() { + if (loaded) return; + + Controlify.LOGGER.info("Initialising SDL2 native library"); + + osNativePath = getNativesPathForOS().orElseGet(() -> { + Controlify.LOGGER.warn("No native library found for SDL2"); + return null; + }); + + if (osNativePath == null) return; + + if (!loadCachedLibrary()) { + downloadLibrary(); + + if (!loadCachedLibrary()) { + Controlify.LOGGER.warn("Failed to download and load SDL2 native library"); + } + } + } + + private static void startSDL2() { + SDL.SDL_SetHint("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", "1"); + SDL.SDL_SetHint("SDL_ACCELEROMETER_AS_JOYSTICK", "0"); + SDL.SDL_SetHint("SDL_MAC_BACKGROUND_APP", "1"); + SDL.SDL_SetHint("SDL_XINPUT_ENABLED", "1"); + SDL.SDL_SetHint("SDL_JOYSTICK_RAWINPUT", "0"); + + int joystickSubsystem = 0x00000200; // implies event subsystem + if (SDL.SDL_Init(joystickSubsystem) != 0) { + Controlify.LOGGER.error("Failed to initialise SDL2: " + SDL.SDL_GetError()); + throw new RuntimeException("Failed to initialise SDL2: " + SDL.SDL_GetError()); + } + + Controlify.LOGGER.info("Initialised SDL2"); + } + + private static boolean loadCachedLibrary() { + if (!Files.exists(osNativePath)) return false; + + Controlify.LOGGER.info("Loading SDL2 native library from " + osNativePath); + + try { + SDL.load(osNativePath); + + startSDL2(); + + loaded = true; + return true; + } catch (UnsatisfiedLinkError e) { + e.printStackTrace(); + return false; + } + } + + private static boolean downloadLibrary() { + Controlify.LOGGER.info("Downloading SDL2 native library"); + + try { + Files.deleteIfExists(osNativePath); + Files.createDirectories(osNativePath.getParent()); + Files.createFile(osNativePath); + } catch (IOException e) { + e.printStackTrace(); + return false; + } + try(FileOutputStream fileOutputStream = new FileOutputStream(osNativePath.toFile())) { + String downloadUrl = NATIVE_LIBRARY_URL + NATIVE_LIBRARIES.get(getNativeLibraryType()); + URL url = new URL(downloadUrl); + ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream()); + FileChannel fileChannel = fileOutputStream.getChannel(); + fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE); + Controlify.LOGGER.info("Downloaded SDL2 native library from " + downloadUrl); + } catch (IOException e) { + e.printStackTrace(); + return false; + } + + return true; + } + + private static Target getNativeLibraryType() { + Util.OS os = Util.getPlatform(); + boolean is64bit = System.getProperty("os.arch").contains("64"); + + return new Target(os, is64bit); + } + + private static Optional getNativesPathForOS() { + String path = NATIVE_LIBRARIES.get(getNativeLibraryType()); + + if (path == null) { + Controlify.LOGGER.warn("No native library found for SDL " + getNativeLibraryType()); + return Optional.empty(); + } + + return Optional.of(NATIVES_FOLDER.resolve(path)); + } + + public static boolean isLoaded() { + return loaded; + } + + private record Target(Util.OS os, boolean is64Bit) { + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/VibrationOnboardingScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/VibrationOnboardingScreen.java new file mode 100644 index 0000000..c68434c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/gui/screen/VibrationOnboardingScreen.java @@ -0,0 +1,25 @@ +package dev.isxander.controlify.gui.screen; + +import dev.isxander.controlify.Controlify; +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +public class VibrationOnboardingScreen extends ConfirmScreen { + public VibrationOnboardingScreen(Screen lastScreen, BooleanConsumer onAnswered) { + super( + yes -> { + Controlify.instance().config().globalSettings().loadVibrationNatives = yes; + Controlify.instance().config().globalSettings().vibrationOnboarded = true; + Controlify.instance().config().save(); + Minecraft.getInstance().setScreen(lastScreen); + onAnswered.accept(yes); + }, + Component.translatable("controlify.vibration_onboarding.title").withStyle(ChatFormatting.BOLD), + Component.translatable("controlify.vibration_onboarding.message") + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java b/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java index 214b014..87b864e 100644 --- a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java +++ b/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java @@ -62,16 +62,23 @@ public class InGameInputHandler { } protected void handlePlayerLookInput() { + var player = this.minecraft.player; + var impulseY = controller.bindings().LOOK_DOWN.state() - controller.bindings().LOOK_UP.state(); var impulseX = controller.bindings().LOOK_RIGHT.state() - controller.bindings().LOOK_LEFT.state(); - if (minecraft.mouseHandler.isMouseGrabbed() && minecraft.isWindowActive() && minecraft.player != null) { + if (minecraft.mouseHandler.isMouseGrabbed() && minecraft.isWindowActive() && player != null) { lookInputX = impulseX * Math.abs(impulseX) * controller.config().horizontalLookSensitivity; lookInputY = impulseY * Math.abs(impulseY) * controller.config().verticalLookSensitivity; - if (controller.config().reduceBowSensitivity && minecraft.player.getUseItem().getItem() instanceof ProjectileWeaponItem) { - lookInputX *= Math.abs(impulseX) * 0.6; - lookInputY *= Math.abs(impulseY) * 0.6; + if (controller.config().reduceAimingSensitivity && player.isUsingItem()) { + float aimMultiplier = switch (player.getUseItem().getUseAnimation()) { + case BOW, CROSSBOW, SPEAR -> 0.6f; + case SPYGLASS -> 0.2f; + default -> 1f; + }; + lookInputX *= aimMultiplier; + lookInputY *= aimMultiplier; } } else { lookInputX = lookInputY = 0; diff --git a/src/main/java/dev/isxander/controlify/mixins/compat/fapi/KeyBindingRegistryImplAccessor.java b/src/main/java/dev/isxander/controlify/mixins/compat/fapi/KeyBindingRegistryImplAccessor.java index fdac4d9..8ba63b0 100644 --- a/src/main/java/dev/isxander/controlify/mixins/compat/fapi/KeyBindingRegistryImplAccessor.java +++ b/src/main/java/dev/isxander/controlify/mixins/compat/fapi/KeyBindingRegistryImplAccessor.java @@ -7,7 +7,7 @@ import org.spongepowered.asm.mixin.gen.Accessor; import java.util.List; -@Mixin(KeyBindingRegistryImpl.class) +@Mixin(value = KeyBindingRegistryImpl.class, remap = false) public interface KeyBindingRegistryImplAccessor { @Accessor("MODDED_KEY_BINDINGS") static List getCustomKeys() { diff --git a/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java index 1876191..2cd6b58 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java @@ -28,13 +28,13 @@ public abstract class MinecraftMixin { @ModifyExpressionValue(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/packs/resources/ReloadableResourceManager;createReload(Ljava/util/concurrent/Executor;Ljava/util/concurrent/Executor;Ljava/util/concurrent/CompletableFuture;Ljava/util/List;)Lnet/minecraft/server/packs/resources/ReloadInstance;")) private ReloadInstance onInputInitialized(ReloadInstance resourceReload) { // Controllers need to be initialized extremely late due to the data-driven nature of controllers. - resourceReload.done().thenRun(() -> Controlify.instance().initializeControllers()); + resourceReload.done().thenRun(() -> Controlify.instance().initializeControlify()); return resourceReload; } @Inject(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyboardHandler;setup(J)V", shift = At.Shift.AFTER)) private void onInputInitialized(CallbackInfo ci) { - Controlify.instance().initializeControlify(); + Controlify.instance().preInitialiseControlify(); } @Inject(method = "runTick", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MouseHandler;turnPlayer()V")) diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/LocalPlayerMixin.java new file mode 100644 index 0000000..4f58daf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/LocalPlayerMixin.java @@ -0,0 +1,31 @@ +package dev.isxander.controlify.mixins.feature.rumble.damage; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.rumble.RumbleEffect; +import net.minecraft.client.player.LocalPlayer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LocalPlayer.class) +public class LocalPlayerMixin { + @Inject(method = "hurtTo", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;setHealth(F)V", ordinal = 1)) + private void onClientHurt(float health, CallbackInfo ci) { + // LivingEntity#hurt is server-side only, so we do it here + doRumble(); + } + + @Inject(method = "hurtTo", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;setHealth(F)V", ordinal = 0)) + private void onClientHealthUpdate(float health, CallbackInfo ci) { + // for some reason fall damage calls hurtTo after the health has been updated at some point + // this is called when hurtTo is set to the same health as the player already has + doRumble(); + } + + private void doRumble() { + ControlifyApi.get().currentController().rumbleManager().play( + RumbleEffect.constant(0.5f, 0f, 5) + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/sounds/LevelRendererMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/sounds/LevelRendererMixin.java new file mode 100644 index 0000000..1778868 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/sounds/LevelRendererMixin.java @@ -0,0 +1,46 @@ +package dev.isxander.controlify.mixins.feature.rumble.sounds; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.rumble.RumbleEffect; +import dev.isxander.controlify.rumble.RumbleState; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.LevelEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LevelRenderer.class) +public class LevelRendererMixin { + @Inject(method = "levelEvent", at = @At("HEAD")) + private void onLevelEvent(int eventId, BlockPos pos, int data, CallbackInfo ci) { + switch (eventId) { + case LevelEvent.SOUND_ANVIL_USED -> rumble( + RumbleEffect.join( + RumbleEffect.constant(1f, 0.5f, 2), + RumbleEffect.empty(5) + ).repeat(3) + ); + } + } + + @Inject(method = "globalLevelEvent", at = @At("HEAD")) + private void onGlobalLevelEvent(int eventId, BlockPos pos, int data, CallbackInfo ci) { + switch (eventId) { + case LevelEvent.SOUND_DRAGON_DEATH -> rumble( + RumbleEffect.join( + RumbleEffect.constant(1f, 1f, 194), + RumbleEffect.byTime(t -> { + float easeOutQuad = 1 - (1 - t) * (1 - t); + return new RumbleState(1 - easeOutQuad, 1 - easeOutQuad); + }, 63) + ) + ); + } + } + + private void rumble(RumbleEffect effect) { + ControlifyApi.get().currentController().rumbleManager().play(effect); + } +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LivingEntityMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LivingEntityMixin.java new file mode 100644 index 0000000..c9f5216 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LivingEntityMixin.java @@ -0,0 +1,23 @@ +package dev.isxander.controlify.mixins.feature.rumble.useitem; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.rumble.RumbleEffect; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityMixin { + @Shadow public abstract int getUseItemRemainingTicks(); + + @Inject(method = "updateUsingItem", at = @At("HEAD")) + protected void onUpdateUsingItem(ItemStack stack, CallbackInfo ci) { + + } +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java new file mode 100644 index 0000000..bcedef7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java @@ -0,0 +1,29 @@ +package dev.isxander.controlify.mixins.feature.rumble.useitem; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.rumble.RumbleEffect; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.item.ItemStack; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LocalPlayer.class) +public abstract class LocalPlayerMixin extends LivingEntityMixin { + @Override + protected void onUpdateUsingItem(ItemStack stack, CallbackInfo ci) { + switch (stack.getUseAnimation()) { + case BOW, CROSSBOW, SPEAR -> { + var magnitude = Mth.clamp((stack.getUseDuration() - getUseItemRemainingTicks()) / 20f, 0f, 1f) * 0.5f; + playRumble(RumbleEffect.constant(magnitude * 0.3f, magnitude, 1)); + } + case BLOCK, SPYGLASS -> playRumble(RumbleEffect.constant(0f, 0.1f, 1)); + case EAT, DRINK -> playRumble(RumbleEffect.constant(0.05f, 0.1f, 1)); + case TOOT_HORN -> playRumble(RumbleEffect.constant(1f, 0.25f, 1)); + } + } + + private void playRumble(RumbleEffect effect) { + ControlifyApi.get().currentController().rumbleManager().play(effect); + } +} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java b/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java new file mode 100644 index 0000000..7d81dcb --- /dev/null +++ b/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java @@ -0,0 +1,7 @@ +package dev.isxander.controlify.rumble; + +public interface RumbleCapable { + boolean setRumble(float strongMagnitude, float weakMagnitude); + + boolean canRumble(); +} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java b/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java new file mode 100644 index 0000000..be97ef5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java @@ -0,0 +1,77 @@ +package dev.isxander.controlify.rumble; + +import org.apache.commons.lang3.Validate; + +import java.util.function.Function; + +public record RumbleEffect(RumbleState[] states) { + /** + * Creates a rumble effect where the state is determined by the tick. + * @param stateFunction the function that takes a tick and returns the state for that tick. + * @param durationTicks how many ticks the effect should last for. + */ + public static RumbleEffect byTick(Function stateFunction, int durationTicks) { + RumbleState[] states = new RumbleState[durationTicks]; + for (int i = 0; i < durationTicks; i++) { + states[i] = stateFunction.apply(i); + } + return new RumbleEffect(states); + } + + /** + * Creates a rumble effect from a function that takes a time value from 0, start, to 1, end, and returns that tick. + * @param stateFunction the function that takes the time value and returns the state for that tick. + * @param durationTicks how many ticks the effect should last for. + */ + public static RumbleEffect byTime(Function stateFunction, int durationTicks) { + return RumbleEffect.byTick(tick -> stateFunction.apply((float) tick / (float) durationTicks), durationTicks); + } + + /** + * Creates a rumble effect that has a constant state. + * @param strong the strong motor magnitude. + * @param weak the weak motor magnitude + * @param durationTicks how many ticks the effect should last for. + */ + public static RumbleEffect constant(float strong, float weak, int durationTicks) { + return RumbleEffect.byTick(tick -> new RumbleState(strong, weak), durationTicks); + } + + public static RumbleEffect empty(int durationTicks) { + return RumbleEffect.byTick(tick -> new RumbleState(0f, 0f), durationTicks); + } + + public static RumbleEffect join(RumbleEffect... effects) { + int totalTicks = 0; + for (RumbleEffect effect : effects) { + totalTicks += effect.states().length; + } + + RumbleState[] states = new RumbleState[totalTicks]; + int currentTick = 0; + for (RumbleEffect effect : effects) { + for (RumbleState state : effect.states()) { + states[currentTick] = state; + currentTick++; + } + } + + return new RumbleEffect(states); + } + + public RumbleEffect join(RumbleEffect other) { + return RumbleEffect.join(this, other); + } + + public RumbleEffect repeat(int count) { + Validate.isTrue(count > 0, "count must be greater than 0"); + + if (count == 1) return this; + + RumbleEffect effect = this; + for (int i = 0; i < count - 1; i++) { + effect = RumbleEffect.join(effect, this); + } + return effect; + } +} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java b/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java new file mode 100644 index 0000000..90f84f2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java @@ -0,0 +1,46 @@ +package dev.isxander.controlify.rumble; + +public class RumbleManager { + private final RumbleCapable controller; + private RumbleEffect playingEffect; + private int currentPlayingTick; + + public RumbleManager(RumbleCapable controller) { + this.controller = controller; + } + + public void play(RumbleEffect effect) { + if (!controller.canRumble()) + return; + + playingEffect = effect; + currentPlayingTick = 0; + } + + public boolean isPlaying() { + return playingEffect != null; + } + + public void stopCurrentEffect() { + if (playingEffect == null) + return; + + controller.setRumble(0f, 0f); + playingEffect = null; + currentPlayingTick = 0; + } + + public void tick() { + if (playingEffect == null) + return; + + if (currentPlayingTick >= playingEffect.states().length) { + stopCurrentEffect(); + return; + } + + RumbleState state = playingEffect.states()[currentPlayingTick]; + controller.setRumble(state.strong(), state.weak()); + currentPlayingTick++; + } +} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleState.java b/src/main/java/dev/isxander/controlify/rumble/RumbleState.java new file mode 100644 index 0000000..87e2aba --- /dev/null +++ b/src/main/java/dev/isxander/controlify/rumble/RumbleState.java @@ -0,0 +1,4 @@ +package dev.isxander.controlify.rumble; + +public record RumbleState(float strong, float weak) { +} diff --git a/src/main/resources/assets/controlify/controllers/controller_identification.json5 b/src/main/resources/assets/controlify/controllers/controller_identification.json5 index 3b34ccc..e47d88a 100644 --- a/src/main/resources/assets/controlify/controllers/controller_identification.json5 +++ b/src/main/resources/assets/controlify/controllers/controller_identification.json5 @@ -17,6 +17,7 @@ 0x202, 0x285, 0x288, + 0xb13, ] }, { diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json index ec4e833..20ddacb 100644 --- a/src/main/resources/assets/controlify/lang/en_us.json +++ b/src/main/resources/assets/controlify/lang/en_us.json @@ -2,6 +2,9 @@ "controlify.gui.category.global": "Global", "controlify.gui.current_controller": "Current Controller", "controlify.gui.current_controller.tooltip": "In Controlify's infancy, only one controller can be used at a time, this selects which one you want to use.", + "controlify.gui.load_vibration_natives": "Vibration Support", + "controlify.gui.load_vibration_natives.tooltip": "If enabled, Controlify will download and load native libraries on launch to enable vibration support. 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.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.", @@ -28,6 +31,8 @@ "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.allow_vibrations": "Allow Vibration", + "controlify.gui.allow_vibrations.tooltip": "If the controller should vibrate when you do certain actions.", "controlify.gui.show_ingame_guide": "Show Ingame Button Guide", "controlify.gui.show_ingame_guide.tooltip": "Show a HUD in-game displaying actions you can do with controller buttons.", "controlify.gui.show_screen_guide": "Show Screen Button Guide", @@ -38,8 +43,8 @@ "controlify.gui.chat_screen_offset.tooltip": "How far up the system on-screen keyboard is. This shifts up the chat box so you can see the chat whilst typing.\nThis is extremely useful on the steamdeck.", "controlify.gui.controller_theme": "Controller Theme", "controlify.gui.controller_theme.tooltip": "The theme to use for rendering controller buttons.", - "controlify.gui.reduce_bow_sensitivity": "Reduce Bow Sensitivity", - "controlify.gui.reduce_bow_sensitivity.tooltip": "Reduce the sensitivity of bow-like items when aiming.", + "controlify.gui.reduce_aiming_sensitivity": "Reduce Aiming Sensitivity", + "controlify.gui.reduce_aiming_sensitivity.tooltip": "Reduce the sensitivity when aiming.", "controlify.gui.custom_name": "Display Name", "controlify.gui.custom_name.tooltip": "Name to display for this controller throughout Minecraft.", "controlify.gui.group.advanced": "Advanced", @@ -55,6 +60,8 @@ "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.", + "controlify.gui.test_vibration": "Test Vibration", + "controlify.gui.test_vibration.tooltip": "Test the vibration of your controller.", "controlify.gui.group.controls": "Controls", "controlify.gui.group.controls.tooltip": "Adjust the controller controls.", @@ -80,6 +87,9 @@ "controlify.toast.faulty_input.title": "Controller disabled", "controlify.toast.faulty_input.description": "Your controller has been disabled because Controlify detected it was causing you problems using keyboard and mouse input. This is likely due to setting your deadzone values too low or your joystick is not mapped properly, making the controller think it is always giving input.", + "controlify.vibration_onboarding.title": "Controlify Vibration Support", + "controlify.vibration_onboarding.message": "To enable vibration support, a native library must be downloaded that Controlify loads automatically. This is a seamless process and will only take a few seconds. If you choose no, you may change your mind later in Controlify settings.\n\nWould you like to download them?", + "controlify.controller_theme.default": "Default", "controlify.controller_theme.xbox_one": "Xbox", "controlify.controller_theme.dualshock4": "PS4", diff --git a/src/main/resources/controlify.mixins.json b/src/main/resources/controlify.mixins.json index b984295..fcc8961 100644 --- a/src/main/resources/controlify.mixins.json +++ b/src/main/resources/controlify.mixins.json @@ -8,7 +8,9 @@ "compat.sodium.CycleControlElementMixin", "compat.sodium.SliderControlElementMixin", "compat.sodium.TickBoxControlElementMixin", - "core.GLXMixin" + "core.GLXMixin", + "feature.rumble.sounds.LevelRendererMixin", + "feature.rumble.useitem.LivingEntityMixin" ], "client": [ "compat.fapi.KeyBindingRegistryImplAccessor", @@ -33,6 +35,8 @@ "feature.guide.screen.AbstractWidgetMixin", "feature.guide.screen.TabNavigationBarMixin", "feature.reacharound.GameRendererMixin", + "feature.rumble.damage.LocalPlayerMixin", + "feature.rumble.useitem.LocalPlayerMixin", "feature.screenop.MinecraftMixin", "feature.screenop.ScreenMixin", "feature.screenop.vanilla.AbstractButtonMixin", diff --git a/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java b/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java index 2e8145d..e61acd1 100644 --- a/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java +++ b/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -44,10 +45,18 @@ public class ClientTestHelper { return Minecraft.getInstance().submit(() -> function.apply(Minecraft.getInstance())); } + private static CompletableFuture submit(Consumer consumer) { + return Minecraft.getInstance().submit(() -> consumer.accept(Minecraft.getInstance())); + } + public static T submitAndWait(Function function) { return submit(function).join(); } + public static void submitConsumerAndWait(Consumer consumer) { + submit(consumer).join(); + } + public static void takeScreenshot(String name) { AtomicBoolean returned = new AtomicBoolean(false); submitAndWait(mc -> { diff --git a/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java b/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java index 6033c36..8ee3b7d 100644 --- a/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java +++ b/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java @@ -2,6 +2,8 @@ package dev.isxander.controlify.test; import com.google.common.collect.Lists; import net.fabricmc.api.ClientModInitializer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.TitleScreen; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +73,7 @@ public class ControlifyAutoTestClient implements ClientModInitializer { } waitForLoadingComplete(); + submitConsumerAndWait(client -> client.setScreen(new TitleScreen())); for (var test : postLoadTests) { success &= wrapTestExecution(test); diff --git a/src/testmod/java/dev/isxander/controlify/test/FakeController.java b/src/testmod/java/dev/isxander/controlify/test/FakeController.java index 1f81c35..2e02362 100644 --- a/src/testmod/java/dev/isxander/controlify/test/FakeController.java +++ b/src/testmod/java/dev/isxander/controlify/test/FakeController.java @@ -11,6 +11,8 @@ import dev.isxander.controlify.controller.joystick.JoystickController; import dev.isxander.controlify.controller.joystick.JoystickState; import dev.isxander.controlify.controller.joystick.mapping.JoystickMapping; import dev.isxander.controlify.controller.joystick.mapping.UnmappedJoystickMapping; +import dev.isxander.controlify.rumble.RumbleCapable; +import dev.isxander.controlify.rumble.RumbleManager; import java.util.List; @@ -22,6 +24,7 @@ public class FakeController implements JoystickController { private final ControllerBindings bindings; private final JoystickConfig config; private JoystickState state = JoystickState.EMPTY, prevState = JoystickState.EMPTY; + private final RumbleManager rumbleManager; private float axisState; private boolean shouldClearAxisNextTick; @@ -34,6 +37,17 @@ public class FakeController implements JoystickController { this.id = -JOYSTICK_COUNT; this.bindings = new ControllerBindings<>(this); this.config = new JoystickConfig(this); + this.rumbleManager = new RumbleManager(new RumbleCapable() { + @Override + public boolean setRumble(float strongMagnitude, float weakMagnitude) { + return false; + } + + @Override + public boolean canRumble() { + return false; + } + }); this.config.calibrated = true; } @@ -114,6 +128,11 @@ public class FakeController implements JoystickController { state = JoystickState.EMPTY; } + @Override + public RumbleManager rumbleManager() { + return rumbleManager; + } + @Override public JoystickMapping mapping() { return UnmappedJoystickMapping.INSTANCE; @@ -170,6 +189,11 @@ public class FakeController implements JoystickController { return true; } + @Override + public void close() { + JoystickController.super.close(); + } + public static class FakeControllerState extends JoystickState { protected FakeControllerState(JoystickMapping mapping, float axis, boolean button, HatState hat) { super(mapping, List.of(axis), List.of(axis), List.of(button), List.of(hat));