1
0
forked from Clones/Controlify

🎮📳 Controller Vibration! (#38)

This commit is contained in:
Xander
2023-04-04 17:17:01 +01:00
committed by GitHub
parent 2bf7cf4792
commit ebbc549e32
32 changed files with 686 additions and 42 deletions

View File

@ -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();

View File

@ -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,
/**

View File

@ -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");

View File

@ -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;
}

View File

@ -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()

View File

@ -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;

View File

@ -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;
}
};
}

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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()

View File

@ -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) {
}
}

View File

@ -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")
);
}
}

View File

@ -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;

View File

@ -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() {

View File

@ -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"))

View File

@ -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)
);
}
}

View File

@ -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);
}
}

View File

@ -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) {
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,7 @@
package dev.isxander.controlify.rumble;
public interface RumbleCapable {
boolean setRumble(float strongMagnitude, float weakMagnitude);
boolean canRumble();
}

View File

@ -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;
}
}

View File

@ -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++;
}
}

View File

@ -0,0 +1,4 @@
package dev.isxander.controlify.rumble;
public record RumbleState(float strong, float weak) {
}