forked from Clones/Controlify
🎮📳 Controller Vibration! (#38)
This commit is contained in:
@ -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();
|
||||
|
@ -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,
|
||||
/**
|
||||
|
@ -40,9 +40,9 @@ public class JoystickHatBind implements IBind<JoystickState> {
|
||||
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");
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<Boolean> 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<Boolean> 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()
|
||||
|
@ -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<S extends ControllerState, C extends ControllerConfig> implements Controller<S, C> {
|
||||
public abstract class AbstractController<S extends ControllerState, C extends ControllerConfig> implements Controller<S, C>, 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<S> bindings;
|
||||
protected C config, defaultConfig;
|
||||
@ -30,12 +36,16 @@ public abstract class AbstractController<S extends ControllerState, C extends Co
|
||||
this.joystickId = joystickId;
|
||||
this.guid = GLFW.glfwGetJoystickGUID(joystickId);
|
||||
|
||||
this.ptrJoystick = SDL2NativesManager.isLoaded() ? SDL.SDL_JoystickOpen(joystickId) : 0;
|
||||
this.rumbleManager = new RumbleManager(this);
|
||||
|
||||
if (hidInfo.path().isPresent()) {
|
||||
this.uid = UUID.nameUUIDFromBytes(hidInfo.path().get().getBytes()).toString();
|
||||
this.uid = hidInfo.createControllerUID().orElseThrow();
|
||||
this.type = hidInfo.type();
|
||||
} else {
|
||||
this.uid = "unidentified-guid-" + UUID.nameUUIDFromBytes(this.guid.getBytes());
|
||||
this.type = ControllerType.UNKNOWN;
|
||||
}
|
||||
this.type = hidInfo.type();
|
||||
|
||||
var joystickName = GLFW.glfwGetJoystickName(joystickId);
|
||||
String name = type != ControllerType.UNKNOWN || joystickName == null ? type.friendlyName() : joystickName;
|
||||
@ -53,8 +63,9 @@ public abstract class AbstractController<S extends ControllerState, C extends Co
|
||||
protected void setName(String name) {
|
||||
String uniqueName = name;
|
||||
int i = 0;
|
||||
while (CONTROLLERS.values().stream().map(Controller::name).anyMatch(name::equalsIgnoreCase)) {
|
||||
while (CONTROLLERS.values().stream().map(Controller::name).anyMatch(uniqueName::equalsIgnoreCase)) {
|
||||
uniqueName = name + " (" + i++ + ")";
|
||||
if (i > 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<S extends ControllerState, C extends Co
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setRumble(float strongMagnitude, float weakMagnitude) {
|
||||
if (!canRumble()) return false;
|
||||
|
||||
// the duration doesn't matter because we are not updating the joystick state,
|
||||
// so there is never any SDL check to stop the rumble after the desired time.
|
||||
if (!SDL.SDL_JoystickRumble(ptrJoystick, (int)(strongMagnitude * 65535.0F), (int)(weakMagnitude * 65535.0F), 1)) {
|
||||
Controlify.LOGGER.error("Could not rumble controller " + name() + ": " + SDL.SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRumble() {
|
||||
return SDL2NativesManager.isLoaded() && config().allowVibrations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RumbleManager rumbleManager() {
|
||||
return this.rumbleManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
SDL.SDL_JoystickClose(ptrJoystick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
|
@ -7,10 +7,12 @@ import dev.isxander.controlify.controller.gamepad.GamepadController;
|
||||
import dev.isxander.controlify.controller.hid.ControllerHIDService;
|
||||
import dev.isxander.controlify.controller.joystick.SingleJoystickController;
|
||||
import dev.isxander.controlify.debug.DebugProperties;
|
||||
import dev.isxander.controlify.rumble.RumbleManager;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface Controller<S extends ControllerState, C extends ControllerConfig> {
|
||||
String uid();
|
||||
@ -33,15 +35,22 @@ public interface Controller<S extends ControllerState, C extends ControllerConfi
|
||||
void updateState();
|
||||
void clearState();
|
||||
|
||||
RumbleManager rumbleManager();
|
||||
|
||||
default boolean canBeUsed() {
|
||||
return true;
|
||||
}
|
||||
|
||||
default void close() {
|
||||
|
||||
}
|
||||
|
||||
Map<String, Controller<?, ?>> CONTROLLERS = new HashMap<>();
|
||||
|
||||
static Controller<?, ?> createOrGet(int joystickId, ControllerHIDService.ControllerHIDInfo hidInfo) {
|
||||
if (CONTROLLERS.containsKey(hidInfo.createControllerUID())) {
|
||||
return CONTROLLERS.get(hidInfo.createControllerUID());
|
||||
Optional<String> 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<S extends ControllerState, C extends ControllerConfi
|
||||
return controller;
|
||||
}
|
||||
|
||||
static void remove(Controller<?, ?> controller) {
|
||||
CONTROLLERS.remove(controller.uid());
|
||||
controller.close();
|
||||
}
|
||||
|
||||
Controller<?, ?> DUMMY = new Controller<>() {
|
||||
private final ControllerBindings<ControllerState> bindings = new ControllerBindings<>(this);
|
||||
private final ControllerConfig config = new ControllerConfig() {
|
||||
@ -133,5 +147,10 @@ public interface Controller<S extends ControllerState, C extends ControllerConfi
|
||||
public void clearState() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public RumbleManager rumbleManager() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -21,7 +21,9 @@ public abstract class ControllerConfig {
|
||||
|
||||
public float chatKeyboardHeight = 0f;
|
||||
|
||||
public boolean reduceBowSensitivity = true;
|
||||
public boolean reduceAimingSensitivity = true;
|
||||
|
||||
public boolean allowVibrations = true;
|
||||
|
||||
public boolean calibrated = false;
|
||||
|
||||
|
@ -78,8 +78,8 @@ public class ControllerHIDService implements HidServicesListener {
|
||||
}
|
||||
|
||||
public record ControllerHIDInfo(ControllerType type, Optional<String> path) {
|
||||
public String createControllerUID() {
|
||||
return UUID.nameUUIDFromBytes(path().get().getBytes()).toString();
|
||||
public Optional<String> createControllerUID() {
|
||||
return path.map(p -> UUID.nameUUIDFromBytes(p.getBytes())).map(UUID::toString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<JoystickConfig> {
|
||||
public class CompoundJoystickController implements JoystickController<JoystickConfig>, RumbleCapable {
|
||||
private final String uid;
|
||||
private final List<Integer> joysticks;
|
||||
private final int axisCount, buttonCount, hatCount;
|
||||
@ -23,6 +25,8 @@ public class CompoundJoystickController implements JoystickController<JoystickCo
|
||||
private JoystickConfig config;
|
||||
private final JoystickConfig defaultConfig;
|
||||
|
||||
private final RumbleManager rumbleManager;
|
||||
|
||||
private JoystickState state = JoystickState.EMPTY, prevState = JoystickState.EMPTY;
|
||||
|
||||
public CompoundJoystickController(List<Integer> joystickIds, String uid, ControllerType compoundType) {
|
||||
@ -39,6 +43,8 @@ public class CompoundJoystickController implements JoystickController<JoystickCo
|
||||
this.config = new JoystickConfig(this);
|
||||
this.defaultConfig = new JoystickConfig(this);
|
||||
|
||||
this.rumbleManager = new RumbleManager(this);
|
||||
|
||||
this.bindings = new ControllerBindings<>(this);
|
||||
}
|
||||
|
||||
@ -133,6 +139,21 @@ public class CompoundJoystickController implements JoystickController<JoystickCo
|
||||
return this.hatCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setRumble(float strongMagnitude, float weakMagnitude) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRumble() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RumbleManager rumbleManager() {
|
||||
return this.rumbleManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canBeUsed() {
|
||||
return JoystickController.super.canBeUsed()
|
||||
|
@ -0,0 +1,137 @@
|
||||
package dev.isxander.controlify.controller.sdl2;
|
||||
|
||||
import dev.isxander.controlify.Controlify;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.minecraft.Util;
|
||||
import org.libsdl.SDL;
|
||||
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
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.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class SDL2NativesManager {
|
||||
private static final Path NATIVES_FOLDER = FabricLoader.getInstance().getGameDir().resolve("controlify-natives");
|
||||
private static final Map<Target, String> 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<Path> 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) {
|
||||
}
|
||||
}
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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<KeyMapping> getCustomKeys() {
|
||||
|
@ -28,13 +28,13 @@ public abstract class MinecraftMixin {
|
||||
@ModifyExpressionValue(method = "<init>", 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 = "<init>", 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"))
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package dev.isxander.controlify.rumble;
|
||||
|
||||
public interface RumbleCapable {
|
||||
boolean setRumble(float strongMagnitude, float weakMagnitude);
|
||||
|
||||
boolean canRumble();
|
||||
}
|
@ -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<Integer, RumbleState> 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<Float, RumbleState> 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;
|
||||
}
|
||||
}
|
@ -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++;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
package dev.isxander.controlify.rumble;
|
||||
|
||||
public record RumbleState(float strong, float weak) {
|
||||
}
|
Reference in New Issue
Block a user